From 1a65a54a25f3e190add07c92b1868e5495346d4a Mon Sep 17 00:00:00 2001 From: kunitoki Date: Sat, 23 May 2026 00:00:09 +0200 Subject: [PATCH 1/3] Spectrogram Component --- AGENTS.md | 233 +++---- CLAUDE.md | 233 +++---- .../source/examples/SpectrumAnalyzer.h | 200 +++++- .../displays/yup_SpectrogramComponent.cpp | 591 ++++++++++++++++++ .../displays/yup_SpectrogramComponent.h | 299 +++++++++ modules/yup_audio_gui/yup_audio_gui.cpp | 1 + modules/yup_audio_gui/yup_audio_gui.h | 1 + modules/yup_graphics/graphics/yup_Color.h | 76 ++- .../yup_graphics/graphics/yup_ColorGradient.h | 60 ++ modules/yup_graphics/imaging/yup_Image.cpp | 128 +++- modules/yup_graphics/imaging/yup_Image.h | 272 ++++++-- .../bindings/yup_YupGraphics_bindings.cpp | 8 +- tests/yup_graphics/yup_Color.cpp | 27 + tests/yup_graphics/yup_ColorGradient.cpp | 40 ++ tests/yup_graphics/yup_Image.cpp | 228 +++++++ 15 files changed, 1961 insertions(+), 436 deletions(-) create mode 100644 modules/yup_audio_gui/displays/yup_SpectrogramComponent.cpp create mode 100644 modules/yup_audio_gui/displays/yup_SpectrogramComponent.h create mode 100644 tests/yup_graphics/yup_Image.cpp diff --git a/AGENTS.md b/AGENTS.md index 11496f428..1a1da0155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,42 @@ This document provides directive guidelines for AI assistants working on the YUP **NEVER EVER run bash commands to configure, compile or test the implementation, acknowledge that we should test and we'll run and report any issue.** +## AI Decision Making Rules + +### Always: +1. **Rely on the C++20 language and standard library** so use it (unless the feature is not supported in all YUP's platforms) +2. **Check existing patterns** in similar modules first +3. **Use YUP conventions** for similar functionality +4. **Use YUP infrastructure** instead of reinventing the wheel +5. **If the same functionality can be provided with less code and complexity** prefer less code +6. **Always prefer reusing code than creating duplicated code** +7. **Prefer composition over inheritance** +8. **Make classes small and focused** (single responsibility) +9. **Use const-correctness** throughout +10. **Do not leak internal details** +11. **Follow the open-closed principle** +12. **Never assume we use plain JUCE7 functionality, always check APIs** as they might have evolved + +### When implementing new features: +1. **Always provide extensive and useful doxygen documentation** for public APIs +2. **Make sure new code is always tested** + +### When writing tests: +1. **Test primarily public interfaces only** +2. **Cover normal, edge, and error cases** +3. **Use descriptive test names** (e.g., `ReturnsNullForInvalidInput`) +4. **Group related tests** in test fixtures +5. **Keep tests independent** and deterministic +6. **Never Use C or C++ macros (like M_PI)** use yup alternatives + +### When suggesting refactoring: +1. **Maintain existing API contracts** +2. **Follow established module patterns** +3. **Preserve platform-specific code organization** +4. **Update tests accordingly** +5. **Consider performance implications** +6. **Keep API usage simple and effective** + ### 1. File Headers **ALWAYS** start new files with this exact header: @@ -71,44 +107,6 @@ For main module headers (e.g., `yup_graphics.h`), include this declaration block Refer to `./docs/YUP Module Format.md` for more info if needed. ### 3. Formatting Rules (Allman Style) -```cpp -// Classes -class MyClass -{ -public: - MyClass(); - ~MyClass(); - -private: - int memberVariable; -}; - -// Functions -void functionName() -{ - // implementation -} - -// Control structures -if (condition) -{ - // code -} -else -{ - // code -} - -for (int i = 0; i < count; ++i) -{ - // code -} - -while (condition) -{ - // code -} -``` ### 4. Naming Conventions - **Classes:** `PascalCase` (e.g., `GraphicsContext`) @@ -125,32 +123,30 @@ while (condition) // 1. Own module header (if in .cpp file) #include -// 2. Standard library -#include -#include - -// 3. External libraries (Rive, etc.) -#include - -// 4. Other project modules +// 2. Other project modules #include "yup_core/yup_core.h" -// 5. Same module headers +// 3. Same module headers #include "graphics/yup_Color.h" #include "primitives/yup_Point.h" + +// 5. External libraries (Rive, etc.) +#include + +// 4. Standard library +#include +#include ``` ### 6. Namespace Usage ```cpp -// NEVER use "using namespace" -// In test files: OK for widely used namespaces +// NEVER use "using namespace" except in test files using namespace yup; // Prefer limited scope usage TEST (MyClassTests, someFunction) { using namespace std::chrono; - // use chrono types without std::chrono:: prefix } ``` @@ -166,8 +162,13 @@ modules/yup_module_name/ │ ├── yup_ClassName.h │ └── yup_ClassName.cpp └── native/ // Platform-specific code - ├── yup_ClassName_win32.cpp + ├── yup_ClassName_android.cpp + ├── yup_ClassName_windows.cpp ├── yup_ClassName_linux.cpp + ├── yup_ClassName_wasm.cpp + ├── yup_ClassName_emscripten.cpp + ├── yup_ClassName_mac.mm + ├── yup_ClassName_ios.mm └── yup_ClassName_apple.mm ``` Avoid going deeply nested into modules. Prefer a single subdirectory whenever possible for YUP modules (might be ok for thirdparties as we don't control the upstream structure). @@ -183,33 +184,6 @@ tests/module_name/ ## Class Design Templates -### Basic Class Template -```cpp -class ClassName -{ -public: - ClassName(); - ~ClassName(); - - // Copy/move constructors if needed - ClassName (const ClassName& other) = delete; - ClassName& operator= (const ClassName& other) = delete; - - // Public interface - void doSomething (int arg); - int getValue() const; - bool isValid() const; - -private: - // Member variables - int value; - bool initialized; - - // Helper methods - void initialize(); -}; -``` - ### YUP-Style Class (with leak detector) ```cpp class YupStyleClass @@ -221,6 +195,8 @@ public: void publicMethod(); private: + void privateMethod(); + int memberVar; YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (YupStyleClass) @@ -239,15 +215,17 @@ using namespace yup; namespace { - // Test helpers and constants - constexpr int kTestValue = 42; - class TestHelper - { - public: - static void setupTestData() { /* ... */ } - }; -} +// Test helpers and constants, prefer move them into fixtures so they don't clash in unity builds +constexpr int kTestValue = 42; + +class TestHelper +{ +public: + static void setupTestData() { /* ... */ } +}; + +} // namespace class ClassNameTests : public ::testing::Test { @@ -279,54 +257,17 @@ TEST (ClassNameTests, StaticMethodBehavesCorrectly) } ``` -## AI Decision Making Rules - -### Always: -1. **Rely on the C++20 language and standard library** so use it (unless the feature is not supported in all YUP's platforms) -2. **Check existing patterns** in similar modules first -3. **Use YUP conventions** for similar functionality -4. **Use YUP infrastructure** instead of reinventing the wheel -5. **Prefer composition over inheritance** -6. **Make classes small and focused** (single responsibility) -6. **Use const-correctness** throughout -7. **Do not leak internal details** -8. **Follow the open-closed principle** -9. **Never assume we use plain JUCE7 functionality, always check APIs** as they might have evolved - -### When implementing new features: -1. **Always provide extensive and useful doxygen documentation** for public APIs -2. **Make sure new code is always tested** - -### When writing tests: -1. **Test primarily public interfaces only** -2. **Cover normal, edge, and error cases** -3. **Use descriptive test names** (e.g., `ReturnsNullForInvalidInput`) -4. **Group related tests** in test fixtures -5. **Keep tests independent** and deterministic -6. **Never Use C or C++ macros (like M_PI)** use yup alternatives - -### When suggesting refactoring: -1. **Maintain existing API contracts** -2. **Follow established module patterns** -3. **Preserve platform-specific code organization** -4. **Update tests accordingly** -5. **Consider performance implications** -6. **Keep API usage simple and effective** - ### Platform-specific code: ```cpp -#if YUP_WINDOWS - // Windows implementation -#elif YUP_MAC - // macOS implementation -#elif YUP_IOS - // iOS implementation -#elif YUP_LINUX - // Linux implementation -#elif YUP_ANDROID - // Android implementation -#elif YUP_WASM - // WebAssembly implementation +#if YUP_WINDOWS // Windows +#elif YUP_MAC // macOS +#elif YUP_IOS // iOS +#elif YUP_LINUX // Linux +#elif YUP_ANDROID // Android +#elif YUP_WASM // WebAssembly (including emscripten) +#elif YUP_EMSCRIPTEN // WebAssembly (only emscripten) +#elif YUP_DESKTOP // Windows/macOS/Linux +#elif YUP_MOBILE // Android/iOS #endif ``` @@ -375,34 +316,6 @@ Before suggesting code, verify: - [ ] Thread safety considerations if applicable - [ ] Documentation for public APIs -## Common Patterns to Follow - -### Resource Management -```cpp -// Prefer RAII and smart pointers -class ResourceManager -{ -private: - std::unique_ptr resource; - std::vector> items; -}; -``` - -### Optional Values -```cpp -// Use yup::var for dynamic types -// Use std::optional for optional values -std::optional findValue (const String& key); -``` - -### String Handling -```cpp -// Use yup::String for most string operations -void processText (const yup::String& text); - -// Use std::string only when interfacing with non-YUP code -``` - ## Differences with JUCE - We use American english in YUP, so it's `center` and not `centred`, or `Color` and not `Colour` diff --git a/CLAUDE.md b/CLAUDE.md index 11496f428..1a1da0155 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,42 @@ This document provides directive guidelines for AI assistants working on the YUP **NEVER EVER run bash commands to configure, compile or test the implementation, acknowledge that we should test and we'll run and report any issue.** +## AI Decision Making Rules + +### Always: +1. **Rely on the C++20 language and standard library** so use it (unless the feature is not supported in all YUP's platforms) +2. **Check existing patterns** in similar modules first +3. **Use YUP conventions** for similar functionality +4. **Use YUP infrastructure** instead of reinventing the wheel +5. **If the same functionality can be provided with less code and complexity** prefer less code +6. **Always prefer reusing code than creating duplicated code** +7. **Prefer composition over inheritance** +8. **Make classes small and focused** (single responsibility) +9. **Use const-correctness** throughout +10. **Do not leak internal details** +11. **Follow the open-closed principle** +12. **Never assume we use plain JUCE7 functionality, always check APIs** as they might have evolved + +### When implementing new features: +1. **Always provide extensive and useful doxygen documentation** for public APIs +2. **Make sure new code is always tested** + +### When writing tests: +1. **Test primarily public interfaces only** +2. **Cover normal, edge, and error cases** +3. **Use descriptive test names** (e.g., `ReturnsNullForInvalidInput`) +4. **Group related tests** in test fixtures +5. **Keep tests independent** and deterministic +6. **Never Use C or C++ macros (like M_PI)** use yup alternatives + +### When suggesting refactoring: +1. **Maintain existing API contracts** +2. **Follow established module patterns** +3. **Preserve platform-specific code organization** +4. **Update tests accordingly** +5. **Consider performance implications** +6. **Keep API usage simple and effective** + ### 1. File Headers **ALWAYS** start new files with this exact header: @@ -71,44 +107,6 @@ For main module headers (e.g., `yup_graphics.h`), include this declaration block Refer to `./docs/YUP Module Format.md` for more info if needed. ### 3. Formatting Rules (Allman Style) -```cpp -// Classes -class MyClass -{ -public: - MyClass(); - ~MyClass(); - -private: - int memberVariable; -}; - -// Functions -void functionName() -{ - // implementation -} - -// Control structures -if (condition) -{ - // code -} -else -{ - // code -} - -for (int i = 0; i < count; ++i) -{ - // code -} - -while (condition) -{ - // code -} -``` ### 4. Naming Conventions - **Classes:** `PascalCase` (e.g., `GraphicsContext`) @@ -125,32 +123,30 @@ while (condition) // 1. Own module header (if in .cpp file) #include -// 2. Standard library -#include -#include - -// 3. External libraries (Rive, etc.) -#include - -// 4. Other project modules +// 2. Other project modules #include "yup_core/yup_core.h" -// 5. Same module headers +// 3. Same module headers #include "graphics/yup_Color.h" #include "primitives/yup_Point.h" + +// 5. External libraries (Rive, etc.) +#include + +// 4. Standard library +#include +#include ``` ### 6. Namespace Usage ```cpp -// NEVER use "using namespace" -// In test files: OK for widely used namespaces +// NEVER use "using namespace" except in test files using namespace yup; // Prefer limited scope usage TEST (MyClassTests, someFunction) { using namespace std::chrono; - // use chrono types without std::chrono:: prefix } ``` @@ -166,8 +162,13 @@ modules/yup_module_name/ │ ├── yup_ClassName.h │ └── yup_ClassName.cpp └── native/ // Platform-specific code - ├── yup_ClassName_win32.cpp + ├── yup_ClassName_android.cpp + ├── yup_ClassName_windows.cpp ├── yup_ClassName_linux.cpp + ├── yup_ClassName_wasm.cpp + ├── yup_ClassName_emscripten.cpp + ├── yup_ClassName_mac.mm + ├── yup_ClassName_ios.mm └── yup_ClassName_apple.mm ``` Avoid going deeply nested into modules. Prefer a single subdirectory whenever possible for YUP modules (might be ok for thirdparties as we don't control the upstream structure). @@ -183,33 +184,6 @@ tests/module_name/ ## Class Design Templates -### Basic Class Template -```cpp -class ClassName -{ -public: - ClassName(); - ~ClassName(); - - // Copy/move constructors if needed - ClassName (const ClassName& other) = delete; - ClassName& operator= (const ClassName& other) = delete; - - // Public interface - void doSomething (int arg); - int getValue() const; - bool isValid() const; - -private: - // Member variables - int value; - bool initialized; - - // Helper methods - void initialize(); -}; -``` - ### YUP-Style Class (with leak detector) ```cpp class YupStyleClass @@ -221,6 +195,8 @@ public: void publicMethod(); private: + void privateMethod(); + int memberVar; YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (YupStyleClass) @@ -239,15 +215,17 @@ using namespace yup; namespace { - // Test helpers and constants - constexpr int kTestValue = 42; - class TestHelper - { - public: - static void setupTestData() { /* ... */ } - }; -} +// Test helpers and constants, prefer move them into fixtures so they don't clash in unity builds +constexpr int kTestValue = 42; + +class TestHelper +{ +public: + static void setupTestData() { /* ... */ } +}; + +} // namespace class ClassNameTests : public ::testing::Test { @@ -279,54 +257,17 @@ TEST (ClassNameTests, StaticMethodBehavesCorrectly) } ``` -## AI Decision Making Rules - -### Always: -1. **Rely on the C++20 language and standard library** so use it (unless the feature is not supported in all YUP's platforms) -2. **Check existing patterns** in similar modules first -3. **Use YUP conventions** for similar functionality -4. **Use YUP infrastructure** instead of reinventing the wheel -5. **Prefer composition over inheritance** -6. **Make classes small and focused** (single responsibility) -6. **Use const-correctness** throughout -7. **Do not leak internal details** -8. **Follow the open-closed principle** -9. **Never assume we use plain JUCE7 functionality, always check APIs** as they might have evolved - -### When implementing new features: -1. **Always provide extensive and useful doxygen documentation** for public APIs -2. **Make sure new code is always tested** - -### When writing tests: -1. **Test primarily public interfaces only** -2. **Cover normal, edge, and error cases** -3. **Use descriptive test names** (e.g., `ReturnsNullForInvalidInput`) -4. **Group related tests** in test fixtures -5. **Keep tests independent** and deterministic -6. **Never Use C or C++ macros (like M_PI)** use yup alternatives - -### When suggesting refactoring: -1. **Maintain existing API contracts** -2. **Follow established module patterns** -3. **Preserve platform-specific code organization** -4. **Update tests accordingly** -5. **Consider performance implications** -6. **Keep API usage simple and effective** - ### Platform-specific code: ```cpp -#if YUP_WINDOWS - // Windows implementation -#elif YUP_MAC - // macOS implementation -#elif YUP_IOS - // iOS implementation -#elif YUP_LINUX - // Linux implementation -#elif YUP_ANDROID - // Android implementation -#elif YUP_WASM - // WebAssembly implementation +#if YUP_WINDOWS // Windows +#elif YUP_MAC // macOS +#elif YUP_IOS // iOS +#elif YUP_LINUX // Linux +#elif YUP_ANDROID // Android +#elif YUP_WASM // WebAssembly (including emscripten) +#elif YUP_EMSCRIPTEN // WebAssembly (only emscripten) +#elif YUP_DESKTOP // Windows/macOS/Linux +#elif YUP_MOBILE // Android/iOS #endif ``` @@ -375,34 +316,6 @@ Before suggesting code, verify: - [ ] Thread safety considerations if applicable - [ ] Documentation for public APIs -## Common Patterns to Follow - -### Resource Management -```cpp -// Prefer RAII and smart pointers -class ResourceManager -{ -private: - std::unique_ptr resource; - std::vector> items; -}; -``` - -### Optional Values -```cpp -// Use yup::var for dynamic types -// Use std::optional for optional values -std::optional findValue (const String& key); -``` - -### String Handling -```cpp -// Use yup::String for most string operations -void processText (const yup::String& text); - -// Use std::string only when interfacing with non-YUP code -``` - ## Differences with JUCE - We use American english in YUP, so it's `center` and not `centred`, or `Color` and not `Colour` diff --git a/examples/graphics/source/examples/SpectrumAnalyzer.h b/examples/graphics/source/examples/SpectrumAnalyzer.h index aa53dbc89..7e89daba3 100644 --- a/examples/graphics/source/examples/SpectrumAnalyzer.h +++ b/examples/graphics/source/examples/SpectrumAnalyzer.h @@ -453,9 +453,17 @@ class SpectrumAnalyzerDemo , public yup::Timer { public: + enum class ViewMode + { + spectrumFilled, + spectrumLines, + spectrogram + }; + SpectrumAnalyzerDemo() : Component ("SpectrumAnalyzerDemo") , analyzerComponent (analyzerState) + , spectrogramComponent (analyzerState) { setupUI(); setupAudio(); @@ -483,16 +491,24 @@ class SpectrumAnalyzerDemo titleLabel->setBounds (titleBounds.reduced (margin, 8)); // Control panel - auto controlHeight = 180; + auto controlHeight = 220; auto controlPanel = bounds.removeFromTop (controlHeight); layoutControlPanel (controlPanel.reduced (margin)); - // Small gap before spectrum analyzer + // Small gap before display bounds.removeFromTop (5); - // Spectrum analyzer takes the rest with proper margins for labels - auto analyzerBounds = bounds.reduced (margin); - analyzerComponent.setBounds (analyzerBounds); + // Display area takes the rest + auto displayBounds = bounds.reduced (margin); + + const bool showingSpectrogram = (currentViewMode == ViewMode::spectrogram); + analyzerComponent.setVisible (! showingSpectrogram); + spectrogramComponent.setVisible (showingSpectrogram); + + if (showingSpectrogram) + spectrogramComponent.setBounds (displayBounds); + else + analyzerComponent.setBounds (displayBounds); } void visibilityChanged() override @@ -564,12 +580,16 @@ class SpectrumAnalyzerDemo double sampleRate = device->getCurrentSampleRate(); const int maxBlockSize = yup::jmax (1, device->getCurrentBufferSizeSamples()); - // Setup signal generator - signalGenerator.prepare (sampleRate, maxBlockSize); - signalGenerator.setFrequency (currentFrequency); - signalGenerator.setAmplitude (currentAmplitude); - signalGenerator.setSweepParameters (20.0, 22000.0, sweepDurationSeconds); - monoOutputBuffer.assign (static_cast (maxBlockSize), 0.0f); + { + const yup::ScopedLock lock (deviceManager.getAudioCallbackLock()); + + // Setup signal generator + signalGenerator.prepare (sampleRate, maxBlockSize); + signalGenerator.setFrequency (currentFrequency); + signalGenerator.setAmplitude (currentAmplitude); + signalGenerator.setSweepParameters (20.0, 22000.0, sweepDurationSeconds); + monoOutputBuffer.assign (static_cast (maxBlockSize), 0.0f); + } // Configure spectrum analyzer analyzerComponent.setSampleRate (sampleRate); @@ -617,7 +637,10 @@ class SpectrumAnalyzerDemo frequencySlider->onValueChanged = [this] (double value) { currentFrequency = (float) value; - signalGenerator.setFrequency ((float) value); + updateSignalGenerator ([value] (SignalGenerator& generator) + { + generator.setFrequency ((float) value); + }); }; addAndMakeVisible (*frequencySlider); @@ -628,7 +651,10 @@ class SpectrumAnalyzerDemo amplitudeSlider->onValueChanged = [this] (double value) { currentAmplitude = (float) value; - signalGenerator.setAmplitude ((float) value); + updateSignalGenerator ([value] (SignalGenerator& generator) + { + generator.setAmplitude ((float) value); + }); }; addAndMakeVisible (*amplitudeSlider); @@ -639,7 +665,10 @@ class SpectrumAnalyzerDemo sweepDurationSlider->onValueChanged = [this] (double value) { sweepDurationSeconds = (float) value; - signalGenerator.setSweepParameters (20.0, 22000.0, (float) value); + updateSignalGenerator ([value] (SignalGenerator& generator) + { + generator.setSweepParameters (20.0, 22000.0, (float) value); + }); }; addAndMakeVisible (*sweepDurationSlider); @@ -716,6 +745,32 @@ class SpectrumAnalyzerDemo }; addAndMakeVisible (*smoothingSlider); + // View mode selector + viewModeCombo = std::make_unique ("ViewMode"); + viewModeCombo->addItem ("Spectrum Filled", 1); + viewModeCombo->addItem ("Spectrum Lines", 2); + viewModeCombo->addItem ("Spectrogram", 3); + viewModeCombo->setSelectedId (1); + viewModeCombo->onSelectedItemChanged = [this] + { + updateViewMode(); + }; + addAndMakeVisible (*viewModeCombo); + + // Color map selector (for spectrogram) + colorMapCombo = std::make_unique ("ColorMap"); + colorMapCombo->addItem ("Heatmap", 1); + colorMapCombo->addItem ("Grayscale", 2); + colorMapCombo->addItem ("Cool", 3); + colorMapCombo->addItem ("Warm", 4); + colorMapCombo->addItem ("Viridis", 5); + colorMapCombo->setSelectedId (1); + colorMapCombo->onSelectedItemChanged = [this] + { + updateColorMap(); + }; + addAndMakeVisible (*colorMapCombo); + // Status labels with appropriate font size auto statusFont = font.withHeight (11.0f); @@ -746,10 +801,21 @@ class SpectrumAnalyzerDemo analyzerComponent.setOverlapFactor (0.75f); // 75% overlap for better responsiveness addAndMakeVisible (analyzerComponent); + // Configure spectrogram + spectrogramComponent.setWindowType (yup::WindowType::hann); + spectrogramComponent.setFrequencyRange (20.0f, 22000.0f); + spectrogramComponent.setDecibelRange (-100.0f, 10.0f); + spectrogramComponent.setUpdateRate (25); + spectrogramComponent.setSampleRate (44100.0); + spectrogramComponent.setOverlapFactor (0.75f); + spectrogramComponent.setColorMap (yup::SpectrogramColorMap::Type::heatmap); + spectrogramComponent.setVisible (false); + addAndMakeVisible (spectrogramComponent); + // Create parameter labels with proper font sizing auto labelFont = font.withHeight (12.0f); - for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "Release:", "Overlap:", "Smoothing:" }) + for (const auto& labelText : { "Signal Type:", "Frequency:", "Amplitude:", "Sweep Duration:", "FFT Size:", "Window:", "Display:", "View Mode:", "Color Map:", "Release:", "Overlap:", "Smoothing:" }) { auto label = parameterLabels.add (std::make_unique (labelText)); label->setText (labelText); @@ -761,6 +827,13 @@ class SpectrumAnalyzerDemo updateSignalType(); } + template + void updateSignalGenerator (Callback&& callback) + { + const yup::ScopedLock lock (deviceManager.getAudioCallbackLock()); + callback (signalGenerator); + } + void setupAudio() { // Initialize audio device @@ -795,16 +868,16 @@ class SpectrumAnalyzerDemo parameterLabels[3]->setBounds (sweepSection.removeFromTop (labelHeight)); sweepDurationSlider->setBounds (sweepSection.removeFromTop (controlHeight)); - parameterLabels[9]->setBounds (smoothingParamSection.removeFromTop (labelHeight)); + parameterLabels[11]->setBounds (smoothingParamSection.removeFromTop (labelHeight)); smoothingSlider->setBounds (smoothingParamSection.removeFromTop (controlHeight)); - // Second row: FFT controls + // Second row: FFT and view mode controls auto row2 = bounds.removeFromTop (rowHeight); auto fftSizeSection = row2.removeFromLeft (colWidth); auto windowSection = row2.removeFromLeft (colWidth); auto displaySection = row2.removeFromLeft (colWidth); - auto releaseSection = row2.removeFromLeft (colWidth); - auto overlapSection = row2.removeFromLeft (colWidth); + auto viewModeSection = row2.removeFromLeft (colWidth); + auto colorMapSection = row2.removeFromLeft (colWidth); parameterLabels[4]->setBounds (fftSizeSection.removeFromTop (labelHeight)); fftSizeCombo->setBounds (fftSizeSection.removeFromTop (controlHeight)); @@ -815,17 +888,28 @@ class SpectrumAnalyzerDemo parameterLabels[6]->setBounds (displaySection.removeFromTop (labelHeight)); displayTypeCombo->setBounds (displaySection.removeFromTop (controlHeight)); - parameterLabels[7]->setBounds (releaseSection.removeFromTop (labelHeight)); + parameterLabels[7]->setBounds (viewModeSection.removeFromTop (labelHeight)); + viewModeCombo->setBounds (viewModeSection.removeFromTop (controlHeight)); + + parameterLabels[8]->setBounds (colorMapSection.removeFromTop (labelHeight)); + colorMapCombo->setBounds (colorMapSection.removeFromTop (controlHeight)); + + // Third row: Release and overlap controls + auto row3 = bounds.removeFromTop (rowHeight); + auto releaseSection = row3.removeFromLeft (colWidth); + auto overlapSection = row3.removeFromLeft (colWidth); + + parameterLabels[9]->setBounds (releaseSection.removeFromTop (labelHeight)); releaseSlider->setBounds (releaseSection.removeFromTop (controlHeight)); - parameterLabels[8]->setBounds (overlapSection.removeFromTop (labelHeight)); + parameterLabels[10]->setBounds (overlapSection.removeFromTop (labelHeight)); overlapSlider->setBounds (overlapSection.removeFromTop (controlHeight)); - // Third row: Status labels - auto row3 = bounds.removeFromTop (30); - auto freqStatus = row3.removeFromLeft (bounds.getWidth() / 3); - auto ampStatus = row3.removeFromLeft (bounds.getWidth() / 3); - auto fftStatus = row3.removeFromLeft (bounds.getWidth() / 3); + // Fourth row: Status labels + auto row4 = bounds.removeFromTop (30); + auto freqStatus = row4.removeFromLeft (bounds.getWidth() / 3); + auto ampStatus = row4.removeFromLeft (bounds.getWidth() / 3); + auto fftStatus = row4.removeFromLeft (bounds.getWidth() / 3); frequencyLabel->setBounds (freqStatus); amplitudeLabel->setBounds (ampStatus); @@ -873,8 +957,11 @@ class SpectrumAnalyzerDemo break; } - signalGenerator.setSignalType (signalType); - signalGenerator.setSweepPlaybackMode (sweepPlaybackMode); + updateSignalGenerator ([signalType, sweepPlaybackMode] (SignalGenerator& generator) + { + generator.setSignalType (signalType); + generator.setSweepPlaybackMode (sweepPlaybackMode); + }); // Enable/disable frequency and sweep controls based on signal type frequencySlider->setEnabled (signalType == SignalGenerator::SignalType::singleTone); @@ -951,9 +1038,58 @@ class SpectrumAnalyzerDemo analyzerComponent.setDisplayType (displayType); } + void updateViewMode() + { + switch (viewModeCombo->getSelectedId()) + { + case 1: + currentViewMode = ViewMode::spectrumFilled; + analyzerComponent.setDisplayType (yup::SpectrumAnalyzerComponent::DisplayType::filled); + break; + case 2: + currentViewMode = ViewMode::spectrumLines; + analyzerComponent.setDisplayType (yup::SpectrumAnalyzerComponent::DisplayType::lines); + break; + case 3: + currentViewMode = ViewMode::spectrogram; + break; + } + + resized(); + } + + void updateColorMap() + { + yup::SpectrogramColorMap::Type colorMapType = yup::SpectrogramColorMap::Type::heatmap; + + switch (colorMapCombo->getSelectedId()) + { + case 1: + colorMapType = yup::SpectrogramColorMap::Type::heatmap; + break; + case 2: + colorMapType = yup::SpectrogramColorMap::Type::grayscale; + break; + case 3: + colorMapType = yup::SpectrogramColorMap::Type::cool; + break; + case 4: + colorMapType = yup::SpectrogramColorMap::Type::warm; + break; + case 5: + colorMapType = yup::SpectrogramColorMap::Type::viridis; + break; + } + + spectrogramComponent.setColorMap (colorMapType); + } + void setSmoothingTime (float timeInSeconds) { - signalGenerator.setSmoothingTime (timeInSeconds); + updateSignalGenerator ([timeInSeconds] (SignalGenerator& generator) + { + generator.setSmoothingTime (timeInSeconds); + }); } // Audio components @@ -963,6 +1099,7 @@ class SpectrumAnalyzerDemo // Spectrum analyzer yup::SpectrumAnalyzerState analyzerState; yup::SpectrumAnalyzerComponent analyzerComponent; + yup::SpectrogramComponent spectrogramComponent; // UI components std::unique_ptr titleLabel; @@ -981,6 +1118,11 @@ class SpectrumAnalyzerDemo std::unique_ptr overlapSlider; std::unique_ptr smoothingSlider; + // View mode controls + std::unique_ptr viewModeCombo; + std::unique_ptr colorMapCombo; + ViewMode currentViewMode = ViewMode::spectrumFilled; + // Status labels std::unique_ptr frequencyLabel; std::unique_ptr amplitudeLabel; diff --git a/modules/yup_audio_gui/displays/yup_SpectrogramComponent.cpp b/modules/yup_audio_gui/displays/yup_SpectrogramComponent.cpp new file mode 100644 index 000000000..be2d37a7d --- /dev/null +++ b/modules/yup_audio_gui/displays/yup_SpectrogramComponent.cpp @@ -0,0 +1,591 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +// SpectrogramColorMap +//============================================================================== + +SpectrogramColorMap::SpectrogramColorMap (Type type, int numStops) + : numColorStops (jmax (2, numStops)) +{ + switch (type) + { + case Type::heatmap: + buildHeatmap(); + break; + case Type::grayscale: + buildGrayscale(); + break; + case Type::cool: + buildCool(); + break; + case Type::warm: + buildWarm(); + break; + case Type::viridis: + buildViridis(); + break; + } +} + +uint32 SpectrogramColorMap::map (float normalizedMagnitude) const noexcept +{ + const float clamped = jlimit (0.0f, 1.0f, normalizedMagnitude); + const float index = clamped * static_cast (numColorStops - 1); + const int i0 = static_cast (index); + const int i1 = jmin (i0 + 1, numColorStops - 1); + const float frac = index - static_cast (i0); + + const uint32 c0 = colorTable[static_cast (i0)]; + const uint32 c1 = colorTable[static_cast (i1)]; + + const uint8 a = static_cast ( + static_cast ((c0 >> 24) & 0xFF) * (1.0f - frac) + static_cast ((c1 >> 24) & 0xFF) * frac); + const uint8 r = static_cast ( + static_cast ((c0 >> 16) & 0xFF) * (1.0f - frac) + static_cast ((c1 >> 16) & 0xFF) * frac); + const uint8 g = static_cast ( + static_cast ((c0 >> 8) & 0xFF) * (1.0f - frac) + static_cast ((c1 >> 8) & 0xFF) * frac); + const uint8 b = static_cast ( + static_cast (c0 & 0xFF) * (1.0f - frac) + static_cast (c1 & 0xFF) * frac); + + return (static_cast (a) << 24) + | (static_cast (r) << 16) + | (static_cast (g) << 8) + | static_cast (b); +} + +static ColorGradient::ColorStop makeColorStop (float delta, uint8 r, uint8 g, uint8 b) +{ + return { Color (r, g, b), 0.0f, 0.0f, delta }; +} + +void SpectrogramColorMap::buildFromGradient (ColorGradient gradient) +{ + colorTable.resize (static_cast (numColorStops)); + gradient.fillGradient (colorTable); +} + +void SpectrogramColorMap::buildHeatmap() +{ + buildFromGradient ({ ColorGradient::Linear, + { + makeColorStop (0.00f, 0, 0, 0), + makeColorStop (0.15f, 0, 0, 64), + makeColorStop (0.30f, 0, 0, 255), + makeColorStop (0.45f, 0, 255, 255), + makeColorStop (0.60f, 0, 255, 0), + makeColorStop (0.75f, 255, 255, 0), + makeColorStop (0.90f, 255, 0, 0), + makeColorStop (1.00f, 255, 255, 255), + } }); +} + +void SpectrogramColorMap::buildGrayscale() +{ + buildFromGradient ({ ColorGradient::Linear, + { + makeColorStop (0.00f, 0, 0, 0), + makeColorStop (1.00f, 255, 255, 255), + } }); +} + +void SpectrogramColorMap::buildCool() +{ + buildFromGradient ({ ColorGradient::Linear, + { + makeColorStop (0.00f, 0, 0, 0), + makeColorStop (0.33f, 0, 0, 255), + makeColorStop (0.66f, 0, 255, 255), + makeColorStop (1.00f, 255, 255, 255), + } }); +} + +void SpectrogramColorMap::buildWarm() +{ + buildFromGradient ({ ColorGradient::Linear, + { + makeColorStop (0.00f, 0, 0, 0), + makeColorStop (0.33f, 255, 0, 0), + makeColorStop (0.66f, 255, 165, 0), + makeColorStop (1.00f, 255, 255, 255), + } }); +} + +void SpectrogramColorMap::buildViridis() +{ + buildFromGradient ({ ColorGradient::Linear, + { + makeColorStop (0.00f, 68, 1, 84), + makeColorStop (0.20f, 59, 82, 139), + makeColorStop (0.40f, 33, 145, 140), + makeColorStop (0.60f, 94, 201, 98), + makeColorStop (0.80f, 181, 222, 43), + makeColorStop (1.00f, 253, 231, 37), + } }); +} + +//============================================================================== +// SpectrogramComponent +//============================================================================== + +SpectrogramComponent::SpectrogramComponent (SpectrumAnalyzerState& state) + : analyzerState (state) + , displayMagnitudes (defaultSpectrogramWidth, 0.0f) + , colorMap (SpectrogramColorMap::Type::heatmap) +{ + fftSize = analyzerState.getFftSize(); + + initializeFFTBuffers(); + generateWindow(); + + startTimerHz (25); // 25 FPS updates +} + +SpectrogramComponent::~SpectrogramComponent() +{ + stopTimer(); +} + +//============================================================================== +void SpectrogramComponent::initializeFFTBuffers() +{ + fftProcessor = std::make_unique (fftSize); + fftInputBuffer.resize (static_cast (fftSize), 0.0f); + fftOutputBuffer.resize (static_cast (fftSize * 2), 0.0f); + windowBuffer.resize (static_cast (fftSize), 0.0f); + + const int numBins = fftSize / 2 + 1; + magnitudeBuffer.resize (static_cast (numBins), 0.0f); +} + +void SpectrogramComponent::ensureImageSize() +{ + const int currentWidth = spectrogramImage.isValid() ? spectrogramImage.getWidth() : 0; + const int currentHeight = spectrogramImage.isValid() ? spectrogramImage.getHeight() : 0; + + if (currentWidth != spectrogramWidth || currentHeight != numHistoryFrames) + { + spectrogramImage = Image (spectrogramWidth, numHistoryFrames, PixelFormat::RGBA); + spectrogramImage.fill (0xFF0a0a0a); + } +} + +//============================================================================== +void SpectrogramComponent::timerCallback() +{ + if (! isShowing()) + return; + + bool hasNewData = false; + int fftCount = 0; + + constexpr int maxFFTsPerFrame = 4; + + while (analyzerState.isFFTDataReady() && fftCount < maxFFTsPerFrame) + { + processFFT(); + hasNewData = true; + ++fftCount; + } + + if (hasNewData) + updateSpectrogramImage(); + + repaint(); +} + +void SpectrogramComponent::processFFT() +{ + if (! analyzerState.getFFTData (fftInputBuffer.data())) + return; + + if (needsWindowUpdate) + { + needsWindowUpdate = false; + generateWindow(); + } + + // Apply window function + for (int i = 0; i < fftSize; ++i) + fftInputBuffer[static_cast (i)] *= windowBuffer[static_cast (i)]; + + // Perform FFT + fftProcessor->performRealFFTForward (fftInputBuffer.data(), fftOutputBuffer.data()); + + // Compute magnitudes with window gain compensation + const int numBins = fftSize / 2 + 1; + + for (int binIndex = 0; binIndex < numBins; ++binIndex) + { + const float real = fftOutputBuffer[static_cast (binIndex * 2)]; + const float imag = fftOutputBuffer[static_cast (binIndex * 2 + 1)]; + magnitudeBuffer[static_cast (binIndex)] = std::sqrt (real * real + imag * imag) * windowGain; + } + + // Map FFT bins to display bins using logarithmic frequency scaling + const int numDisplayBins = spectrogramWidth; + + for (int i = 0; i < numDisplayBins; ++i) + { + const float proportion = static_cast (i) / static_cast (numDisplayBins - 1); + const float logFreq = logMinFrequency + proportion * (logMaxFrequency - logMinFrequency); + const float centerFreq = std::pow (10.0f, logFreq); + + float freqRangeStart, freqRangeEnd; + + if (i == 0) + { + freqRangeStart = minFrequency; + const float nextLogFreq = logMinFrequency + (static_cast (i + 1) / static_cast (numDisplayBins - 1)) * (logMaxFrequency - logMinFrequency); + const float nextFreq = std::pow (10.0f, nextLogFreq); + freqRangeEnd = (centerFreq + nextFreq) * 0.5f; + } + else if (i == numDisplayBins - 1) + { + const float prevLogFreq = logMinFrequency + (static_cast (i - 1) / static_cast (numDisplayBins - 1)) * (logMaxFrequency - logMinFrequency); + const float prevFreq = std::pow (10.0f, prevLogFreq); + freqRangeStart = (prevFreq + centerFreq) * 0.5f; + freqRangeEnd = maxFrequency; + } + else + { + const float prevLogFreq = logMinFrequency + (static_cast (i - 1) / static_cast (numDisplayBins - 1)) * (logMaxFrequency - logMinFrequency); + const float nextLogFreq = logMinFrequency + (static_cast (i + 1) / static_cast (numDisplayBins - 1)) * (logMaxFrequency - logMinFrequency); + const float prevFreq = std::pow (10.0f, prevLogFreq); + const float nextFreq = std::pow (10.0f, nextLogFreq); + freqRangeStart = (prevFreq + centerFreq) * 0.5f; + freqRangeEnd = (centerFreq + nextFreq) * 0.5f; + } + + const float startBin = (freqRangeStart * static_cast (fftSize)) / static_cast (sampleRate); + const float endBin = (freqRangeEnd * static_cast (fftSize)) / static_cast (sampleRate); + const float binSpan = endBin - startBin; + + float magnitude = 0.0f; + + if (binSpan <= 1.5f) + { + const float exactBin = (centerFreq * static_cast (fftSize)) / static_cast (sampleRate); + const int bin1 = jlimit (0, numBins - 1, static_cast (exactBin)); + const int bin2 = jlimit (0, numBins - 1, bin1 + 1); + const float fraction = exactBin - static_cast (bin1); + + const float mag1 = magnitudeBuffer[static_cast (bin1)]; + const float mag2 = magnitudeBuffer[static_cast (bin2)]; + magnitude = mag1 + fraction * (mag2 - mag1); + } + else + { + const int binStart = jlimit (0, numBins - 1, static_cast (startBin)); + const int binEnd = jlimit (0, numBins - 1, static_cast (endBin + 0.5f)); + + for (int binIndex = binStart; binIndex <= binEnd; ++binIndex) + magnitude = jmax (magnitude, magnitudeBuffer[static_cast (binIndex)]); + } + + // Convert to decibels and normalize to [0, 1] + float magnitudeDb = magnitude > 0.0f + ? 20.0f * std::log10 (magnitude / static_cast (fftSize)) + : minDecibels; + + displayMagnitudes[static_cast (i)] = jmap ( + jlimit (minDecibels, maxDecibels, magnitudeDb), + minDecibels, + maxDecibels, + 0.0f, + 1.0f); + } +} + +void SpectrogramComponent::updateSpectrogramImage() +{ + ensureImageSize(); + + scrollSpectrogram(); + writeMagnitudeRow(); + + // Invalidate so the GPU texture is recreated from updated pixel data + spectrogramImage.invalidateTexture(); +} + +void SpectrogramComponent::scrollSpectrogram() +{ + auto raw = spectrogramImage.getRawData(); + const int width = spectrogramImage.getWidth(); + const int height = spectrogramImage.getHeight(); + const int stride = spectrogramImage.getPixelStride(); + const int rowBytes = width * stride; + + // Shift all rows down by one (row 0 stays at top, row height-1 falls off) + if (height > 1) + { + std::memmove ( + raw.data() + rowBytes, + raw.data(), + static_cast ((height - 1)) * static_cast (rowBytes)); + } +} + +void SpectrogramComponent::writeMagnitudeRow() +{ + auto& bitmap = spectrogramImage.getBitmapData(); + const int width = spectrogramImage.getWidth(); + + for (int x = 0; x < width; ++x) + { + const float magnitude = displayMagnitudes[static_cast (x)]; + const uint32 color = colorMap.map (magnitude); + bitmap.setPixel (x, 0, color); + } +} + +void SpectrogramComponent::generateWindow() +{ + WindowFunctions::generate (currentWindowType, windowBuffer.data(), windowBuffer.size()); + + float windowSum = 0.0f; + for (int i = 0; i < fftSize; ++i) + windowSum += windowBuffer[static_cast (i)]; + + windowGain = windowSum > 0.0f ? static_cast (fftSize) / windowSum : 1.0f; +} + +//============================================================================== +void SpectrogramComponent::paint (Graphics& g) +{ + const auto bounds = getLocalBounds(); + + // Background + g.setFillColor (Color (0xFF0a0a0a)); + g.fillAll(); + + // Draw the spectrogram image (stretched to fill the component) + if (spectrogramImage.isValid()) + g.drawImage (spectrogramImage, bounds); + + // Draw grid overlays + drawFrequencyGrid (g, bounds); +} + +void SpectrogramComponent::resized() +{ + // Update history frames to match the component height if not explicitly set +} + +//============================================================================== +void SpectrogramComponent::drawFrequencyGrid (Graphics& g, const Rectangle& bounds) +{ + auto font = ApplicationTheme::getGlobalTheme()->getDefaultFont().withHeight (9.0f); + + const int multipliers[] = { 1, 2, 5 }; + const int powers[] = { 1, 10, 100, 1000, 10000 }; + + for (int brightness = 0; brightness < 3; ++brightness) + { + Color lineColor; + float lineWidth; + bool drawLabels = false; + + if (brightness == 0) + { + lineColor = Color (0x50ffffff); + lineWidth = 1.0f; + drawLabels = true; + } + else if (brightness == 1) + { + lineColor = Color (0x28ffffff); + lineWidth = 0.75f; + } + else + { + lineColor = Color (0x12ffffff); + lineWidth = 0.5f; + } + + g.setStrokeColor (lineColor); + g.setStrokeWidth (lineWidth); + + for (int power = 0; power < 5; ++power) + { + float freq = static_cast (multipliers[brightness] * powers[power]); + + if (freq < minFrequency || freq > maxFrequency) + continue; + + const float x = frequencyToX (freq, bounds.getWidth()); + + // Vertical grid line + g.strokeLine ( + bounds.getX() + x, + bounds.getY(), + bounds.getX() + x, + bounds.getBottom()); + + if (! drawLabels) + continue; + + String freqText; + if (freq >= 1000.0f) + freqText = String (freq / 1000.0f, freq == 1000.0f ? 0 : 1) + "k"; + else + freqText = String (static_cast (freq)); + + g.setFillColor (Color (0xFFaaaaaa)); + float labelX = jmax (bounds.getX() + x - 20.0f, bounds.getX()); + labelX = jmin (labelX, bounds.getRight() - 40.0f); + g.fillFittedText (freqText, font, { labelX, bounds.getBottom() - 12.0f, 40.0f, 10.0f }, Justification::center); + } + } +} + +//============================================================================== +float SpectrogramComponent::frequencyToX (float frequency, float width) const noexcept +{ + return jmap (std::log10 (frequency), logMinFrequency, logMaxFrequency, 0.0f, width); +} + +//============================================================================== +void SpectrogramComponent::setFFTSize (int size) +{ + jassert (isPowerOfTwo (size) && size >= 64 && size <= 65536); + + if (fftSize != size) + { + fftSize = size; + analyzerState.setFftSize (size); + + initializeFFTBuffers(); + generateWindow(); + + clearHistory(); + repaint(); + } +} + +void SpectrogramComponent::setWindowType (WindowType type) +{ + if (currentWindowType != type) + { + currentWindowType = type; + needsWindowUpdate = true; + } +} + +void SpectrogramComponent::setUpdateRate (int hz) +{ + stopTimer(); + startTimerHz (jmax (1, jmin (60, hz))); +} + +int SpectrogramComponent::getUpdateRate() const noexcept +{ + const int intervalMs = getTimerInterval(); + return isTimerRunning() ? 1000 / intervalMs : 0; +} + +void SpectrogramComponent::setFrequencyRange (float minFreq, float maxFreq) +{ + const float newMinFrequency = jmax (1.0f, minFreq); + const float newMaxFrequency = jmax (newMinFrequency + 1.0f, maxFreq); + + if (approximatelyEqual (minFrequency, newMinFrequency) + && approximatelyEqual (maxFrequency, newMaxFrequency)) + { + return; + } + + minFrequency = newMinFrequency; + maxFrequency = newMaxFrequency; + logMinFrequency = std::log10 (minFrequency); + logMaxFrequency = std::log10 (maxFrequency); + + clearHistory(); +} + +void SpectrogramComponent::setDecibelRange (float minDb, float maxDb) +{ + jassert (maxDb > minDb); + + minDb = jmin (minDb, maxDb - 1.0f); + + if (approximatelyEqual (minDecibels, minDb) + && approximatelyEqual (maxDecibels, maxDb)) + { + return; + } + + minDecibels = minDb; + maxDecibels = maxDb; + + clearHistory(); +} + +void SpectrogramComponent::setSampleRate (double rate) +{ + const double newSampleRate = jmax (1.0, rate); + + if (approximatelyEqual (sampleRate, newSampleRate)) + return; + + sampleRate = newSampleRate; + clearHistory(); +} + +void SpectrogramComponent::setColorMap (SpectrogramColorMap::Type type) +{ + colorMap = SpectrogramColorMap (type); + clearHistory(); +} + +void SpectrogramComponent::setNumHistoryFrames (int numFrames) +{ + numHistoryFrames = jmax (4, numFrames); + + // Recreate the image with the new height + spectrogramImage = Image (spectrogramWidth, numHistoryFrames, PixelFormat::RGBA); + spectrogramImage.fill (Color (0xFF0a0a0a).getARGB()); + + repaint(); +} + +void SpectrogramComponent::setOverlapFactor (float overlapFactor) +{ + analyzerState.setOverlapFactor (overlapFactor); +} + +float SpectrogramComponent::getOverlapFactor() const noexcept +{ + return analyzerState.getOverlapFactor(); +} + +void SpectrogramComponent::clearHistory() +{ + if (spectrogramImage.isValid()) + spectrogramImage.fill (Color (0xFF0a0a0a).getARGB()); + + repaint(); +} + +} // namespace yup diff --git a/modules/yup_audio_gui/displays/yup_SpectrogramComponent.h b/modules/yup_audio_gui/displays/yup_SpectrogramComponent.h new file mode 100644 index 000000000..3a0451367 --- /dev/null +++ b/modules/yup_audio_gui/displays/yup_SpectrogramComponent.h @@ -0,0 +1,299 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + A color map that converts normalized magnitude values (0.0 to 1.0) into + ARGB colors for spectrogram visualization. + + The default color map produces a professional heatmap gradient: + black → dark blue → blue → cyan → green → yellow → red → white. + + @tags{Audio} +*/ +class YUP_API SpectrogramColorMap +{ +public: + //============================================================================== + /** Predefined color map types. */ + enum class Type + { + heatmap, ///< Black → blue → cyan → green → yellow → red → white + grayscale, ///< Black → gray → white + cool, ///< Black → blue → cyan → white + warm, ///< Black → red → orange → yellow → white + viridis ///< Perceptually uniform blue → green → yellow + }; + + //============================================================================== + /** Creates a color map of the given type with a specified number of color stops. + + @param type The predefined color map type to use. + @param numColorStops Number of entries in the lookup table (default: 256). + */ + SpectrogramColorMap (Type type = Type::heatmap, int numColorStops = 256); + + /** Destructor. */ + ~SpectrogramColorMap() = default; + + //============================================================================== + /** Maps a normalized magnitude value to an ARGB color. + + @param normalizedMagnitude Value in [0.0, 1.0] where 0.0 is minimum and 1.0 is maximum. + @return The corresponding ARGB color (0xAARRGGBB). + */ + uint32 map (float normalizedMagnitude) const noexcept; + + /** Returns the number of color stops in the lookup table. */ + int getNumColorStops() const noexcept { return numColorStops; } + +private: + //============================================================================== + void buildHeatmap(); + void buildGrayscale(); + void buildCool(); + void buildWarm(); + void buildViridis(); + void buildFromGradient (ColorGradient gradient); + + //============================================================================== + std::vector colorTable; + int numColorStops; +}; + +//============================================================================== +/** + A component that displays a real-time scrolling spectrogram (waterfall display). + + This component performs FFT processing on audio data collected by a + SpectrumAnalyzerState and renders the frequency spectrum over time as a + scrolling waterfall display. Each new row of FFT data is rendered at the + top of the display and previous rows scroll downward. + + The spectrogram image is stored in a GPU texture which is updated each frame + by invalidating the existing texture and letting the renderer recreate it. + Frequency and decibel grid lines are drawn as vector overlays. + + Example usage: + + @code + SpectrumAnalyzerState analyzerState; + SpectrogramComponent spectrogram (analyzerState); + + // Configure the display + spectrogram.setFrequencyRange (20.0f, 20000.0f); + spectrogram.setDecibelRange (-100.0f, 0.0f); + spectrogram.setColorMap (SpectrogramColorMap::Type::heatmap); + spectrogram.setUpdateRate (25); + + // In audio callback: + analyzerState.pushSamples (audioData, numSamples); + @endcode + + @see SpectrumAnalyzerState, SpectrumAnalyzerComponent, SpectrogramColorMap +*/ +class YUP_API SpectrogramComponent + : public Component + , public Timer +{ +public: + //============================================================================== + /** Display constants. */ + enum + { + defaultSpectrogramWidth = 512 ///< Default number of horizontal frequency bins. + }; + + //============================================================================== + /** Creates a SpectrogramComponent. + + @param state the SpectrumAnalyzerState that provides audio data + */ + explicit SpectrogramComponent (SpectrumAnalyzerState& state); + + /** Destructor. */ + ~SpectrogramComponent() override; + + //============================================================================== + /** Sets the FFT size for analysis. + + @param size FFT size (must be a power of 2) + */ + void setFFTSize (int size); + + /** Returns the current FFT size. */ + int getFFTSize() const noexcept { return analyzerState.getFftSize(); } + + //============================================================================== + /** Sets the window function used for FFT processing. + + @param type the window function type to use + */ + void setWindowType (WindowType type); + + /** Returns the current window function type. */ + WindowType getWindowType() const noexcept { return currentWindowType; } + + //============================================================================== + /** Sets the display update rate in Hz. + + @param hz update rate (typical values: 10-30 Hz) + */ + void setUpdateRate (int hz); + + /** Returns the current update rate in Hz. */ + int getUpdateRate() const noexcept; + + //============================================================================== + /** Sets the frequency range for the display. + + @param minFreq minimum frequency in Hz + @param maxFreq maximum frequency in Hz + */ + void setFrequencyRange (float minFreq, float maxFreq); + + /** Returns the current minimum frequency. */ + float getMinFrequency() const noexcept { return minFrequency; } + + /** Returns the current maximum frequency. */ + float getMaxFrequency() const noexcept { return maxFrequency; } + + //============================================================================== + /** Sets the decibel range used to normalize FFT magnitudes. + + @param minDb minimum decibel level + @param maxDb maximum decibel level + */ + void setDecibelRange (float minDb, float maxDb); + + /** Returns the current minimum decibel level. */ + float getMinDecibels() const noexcept { return minDecibels; } + + /** Returns the current maximum decibel level. */ + float getMaxDecibels() const noexcept { return maxDecibels; } + + //============================================================================== + /** Sets the sample rate for frequency calculations. + + @param sampleRate the sample rate in Hz + */ + void setSampleRate (double sampleRate); + + /** Returns the current sample rate. */ + double getSampleRate() const noexcept { return sampleRate; } + + //============================================================================== + /** Sets the color map used for the spectrogram. + + @param type the color map type to use + */ + void setColorMap (SpectrogramColorMap::Type type); + + /** Returns a reference to the current color map. */ + const SpectrogramColorMap& getColorMap() const noexcept { return colorMap; } + + //============================================================================== + /** Sets the number of FFT frames kept in the scrolling history. + + This determines how many past FFT frames are visible in the display. + The spectrogram image height is resized to match this value. + + @param numFrames number of history frames (minimum: 4, default: matches component height) + */ + void setNumHistoryFrames (int numFrames); + + /** Returns the number of history frames. */ + int getNumHistoryFrames() const noexcept { return numHistoryFrames; } + + //============================================================================== + /** Sets the overlap factor for more responsive spectrum analysis. + + @param overlapFactor overlap factor (0.0 = no overlap, 0.75 = 75% overlap) + */ + void setOverlapFactor (float overlapFactor); + + /** Returns the current overlap factor. */ + float getOverlapFactor() const noexcept; + + //============================================================================== + /** Clears the spectrogram history, resetting the display. */ + void clearHistory(); + + //============================================================================== + /** @internal */ + void paint (Graphics& g) override; + /** @internal */ + void resized() override; + /** @internal */ + void timerCallback() override; + +private: + //============================================================================== + void processFFT(); + void updateSpectrogramImage(); + void scrollSpectrogram(); + void writeMagnitudeRow(); + void initializeFFTBuffers(); + void generateWindow(); + void ensureImageSize(); + + float frequencyToX (float frequency, float width) const noexcept; + void drawFrequencyGrid (Graphics& g, const Rectangle& bounds); + + //============================================================================== + SpectrumAnalyzerState& analyzerState; + + // FFT processing (performed on UI thread) + std::unique_ptr fftProcessor; + std::vector fftInputBuffer; + std::vector fftOutputBuffer; + std::vector windowBuffer; + std::vector magnitudeBuffer; // Per-bin magnitudes from FFT + std::vector displayMagnitudes; // Magnitudes mapped to display bins + + // Spectrogram image + Image spectrogramImage; + SpectrogramColorMap colorMap; + + // Configuration + WindowType currentWindowType = WindowType::hann; + int fftSize = 4096; + int spectrogramWidth = defaultSpectrogramWidth; + int numHistoryFrames = 256; + float minFrequency = 20.0f; + float maxFrequency = 20000.0f; + float logMinFrequency = std::log10 (20.0f); + float logMaxFrequency = std::log10 (20000.0f); + float minDecibels = -100.0f; + float maxDecibels = 0.0f; + double sampleRate = 44100.0; + + // Window compensation + float windowGain = 1.0f; + bool needsWindowUpdate = true; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SpectrogramComponent) +}; + +} // namespace yup diff --git a/modules/yup_audio_gui/yup_audio_gui.cpp b/modules/yup_audio_gui/yup_audio_gui.cpp index 4a2409062..53ba9bfda 100644 --- a/modules/yup_audio_gui/yup_audio_gui.cpp +++ b/modules/yup_audio_gui/yup_audio_gui.cpp @@ -38,6 +38,7 @@ #include "keyboard/yup_MidiKeyboardComponent.cpp" #include "displays/yup_AudioViewComponent.cpp" #include "displays/yup_SpectrumAnalyzerComponent.cpp" +#include "displays/yup_SpectrogramComponent.cpp" #include "displays/yup_CartesianPlane.cpp" #include "metering/yup_KMeterComponent.cpp" #include "graph/yup_AudioGraphNodeView.cpp" diff --git a/modules/yup_audio_gui/yup_audio_gui.h b/modules/yup_audio_gui/yup_audio_gui.h index 79ecaef4b..e6e97171b 100644 --- a/modules/yup_audio_gui/yup_audio_gui.h +++ b/modules/yup_audio_gui/yup_audio_gui.h @@ -57,6 +57,7 @@ #include "keyboard/yup_MidiKeyboardComponent.h" #include "displays/yup_AudioViewComponent.h" #include "displays/yup_SpectrumAnalyzerComponent.h" +#include "displays/yup_SpectrogramComponent.h" #include "displays/yup_CartesianPlane.h" #include "metering/yup_KMeterComponent.h" #include "graph/yup_AudioGraphNodeView.h" diff --git a/modules/yup_graphics/graphics/yup_Color.h b/modules/yup_graphics/graphics/yup_Color.h index 7d92d0820..fe6b8abe5 100644 --- a/modules/yup_graphics/graphics/yup_Color.h +++ b/modules/yup_graphics/graphics/yup_Color.h @@ -32,20 +32,20 @@ enum class ColorSpace : unsigned int }; //============================================================================== -/** Represents an RGBA color for graphical use. +/** Represents a color for graphical use. - This class encapsulates color information in RGBA format, where each component (red, green, blue, alpha) - is represented as an 8-bit value. It provides methods for adjusting color components individually, - converting to and from HSL (Hue, Saturation, Luminance), and for performing operations like brightening, - darkening, and contrasting. + This class encapsulates color information as 8-bit red, green, blue, and alpha components. + Packed integer APIs use ARGB order (`0xAARRGGBB`) to match Rive ColorInt and the rest of + the YUP graphics API. Use getRGBA(), getBGRA(), fromRGBA(), and fromBGRA() when exchanging + explicitly packed values with systems that use a different component order. */ class YUP_API Color { public: //============================================================================== - /** Default constructor, initializes the color to transparent black. + /** Default constructor, initializes the color to opaque black. - Creates a color with all components set to zero, which is fully transparent black. + Creates a color with red, green, and blue set to zero, and alpha set to fully opaque. */ constexpr Color() noexcept : data (0xff000000) @@ -123,6 +123,34 @@ class YUP_API Color return data; } + /** Returns the color as a 32-bit integer in RGBA format. + + The returned value is packed as `0xRRGGBBAA`. + + @return The color as a 32-bit integer in RGBA component order. + */ + constexpr uint32 getRGBA() const noexcept + { + return (static_cast (r) << 24) + | (static_cast (g) << 16) + | (static_cast (b) << 8) + | static_cast (a); + } + + /** Returns the color as a 32-bit integer in BGRA format. + + The returned value is packed as `0xBBGGRRAA`. + + @return The color as a 32-bit integer in BGRA component order. + */ + constexpr uint32 getBGRA() const noexcept + { + return (static_cast (b) << 24) + | (static_cast (g) << 16) + | (static_cast (r) << 8) + | static_cast (a); + } + /** Explicit conversion to a 32-bit integer. Allows the color to be used wherever a 32-bit integer color is expected. @@ -1006,6 +1034,22 @@ class YUP_API Color return { a, r, g, b }; } + /** Creates a Color object from a packed RGBA value. + + @param colorRGBA The color packed as `0xRRGGBBAA`. + + @return A Color object with the specified RGBA values. + */ + static constexpr Color fromRGBA (uint32 colorRGBA) noexcept + { + return { + static_cast (colorRGBA & 0xFF), + static_cast ((colorRGBA >> 24) & 0xFF), + static_cast ((colorRGBA >> 16) & 0xFF), + static_cast ((colorRGBA >> 8) & 0xFF) + }; + } + /** Creates a Color object from ARGB components. This static method constructs a Color object using the specified alpha, red, green, and blue components. @@ -1038,6 +1082,22 @@ class YUP_API Color return { a, r, g, b }; } + /** Creates a Color object from a packed BGRA value. + + @param colorBGRA The color packed as `0xBBGGRRAA`. + + @return A Color object with the specified BGRA values. + */ + static constexpr Color fromBGRA (uint32 colorBGRA) noexcept + { + return { + static_cast (colorBGRA & 0xFF), + static_cast ((colorBGRA >> 8) & 0xFF), + static_cast ((colorBGRA >> 16) & 0xFF), + static_cast ((colorBGRA >> 24) & 0xFF) + }; + } + //============================================================================== /** Generates a random opaque color. @@ -1107,6 +1167,8 @@ class YUP_API Color { uint32 data = 0xff000000; + // Component access reflects little-endian storage for the ARGB integer. + // Public APIs should expose explicit packed formats instead of relying on this layout. struct { uint8 b, g, r, a; diff --git a/modules/yup_graphics/graphics/yup_ColorGradient.h b/modules/yup_graphics/graphics/yup_ColorGradient.h index c1a4aa23c..45672ec32 100644 --- a/modules/yup_graphics/graphics/yup_ColorGradient.h +++ b/modules/yup_graphics/graphics/yup_ColorGradient.h @@ -342,6 +342,66 @@ class YUP_API ColorGradient return getColorAt (p.getX(), p.getY()); } + /** Fills a lookup table with colors sampled evenly along this gradient. + + The output values are packed as `0xAARRGGBB`, matching Color, Rive + ColorInt, and image pixel APIs such as BitmapData::setPixel(). + + @param colors the destination lookup table to fill + */ + void fillGradient (Span colors) const noexcept + { + if (colors.empty()) + return; + + if (stops.empty()) + { + std::fill (colors.begin(), colors.end(), Color().getARGB()); + return; + } + + if (stops.size() == 1 || colors.size() == 1) + { + std::fill (colors.begin(), colors.end(), stops.front().color.getARGB()); + return; + } + + size_t stopIndex = 0; + + for (size_t i = 0; i < colors.size(); ++i) + { + const float t = static_cast (i) / static_cast (colors.size() - 1); + + if (t <= stops.front().delta) + { + colors[i] = stops.front().color.getARGB(); + continue; + } + + if (t >= stops.back().delta) + { + colors[i] = stops.back().color.getARGB(); + continue; + } + + while (stopIndex + 1 < stops.size() && stops[stopIndex + 1].delta <= t) + ++stopIndex; + + const auto& start = stops[stopIndex]; + const auto& end = stops[stopIndex + 1]; + const float deltaRange = end.delta - start.delta; + + if (deltaRange <= 0.0f) + { + colors[i] = end.color.getARGB(); + continue; + } + + const float localT = (t - start.delta) / deltaRange; + colors[i] = start.color.mixedWith (end.color, localT, ColorSpace::SRGB).getARGB(); + } + } + /** Adds a color stop to the gradient. @param color The color of the new stop. diff --git a/modules/yup_graphics/imaging/yup_Image.cpp b/modules/yup_graphics/imaging/yup_Image.cpp index 2c8284805..f96475cd2 100644 --- a/modules/yup_graphics/imaging/yup_Image.cpp +++ b/modules/yup_graphics/imaging/yup_Image.cpp @@ -22,6 +22,74 @@ namespace yup { +namespace +{ +uint8 premultiplyComponent (uint8 component, uint8 alpha) noexcept +{ + return static_cast ((static_cast (component) * static_cast (alpha) + 127u) / 255u); +} + +uint8 unpremultiplyComponent (uint8 component, uint8 alpha) noexcept +{ + if (alpha == 0) + return 0; + + const auto value = (static_cast (component) * 255u + static_cast (alpha) / 2u) / static_cast (alpha); + return static_cast (value > 255u ? 255u : value); +} + +void writeARGBAsRGBAPremultiplied (uint32 argb, uint8* destination) noexcept +{ + const auto alpha = static_cast ((argb >> 24) & 0xFF); + + destination[0] = premultiplyComponent (static_cast ((argb >> 16) & 0xFF), alpha); + destination[1] = premultiplyComponent (static_cast ((argb >> 8) & 0xFF), alpha); + destination[2] = premultiplyComponent (static_cast (argb & 0xFF), alpha); + destination[3] = alpha; +} + +std::unique_ptr copyRGBAPremultipliedAsRGBA (const rive::Bitmap& bitmap) +{ + const auto numPixels = static_cast (bitmap.width()) * static_cast (bitmap.height()); + auto destination = std::make_unique (numPixels * 4u); + + const auto* source = bitmap.bytes(); + auto* target = destination.get(); + + for (size_t i = 0; i < numPixels; ++i) + { + const auto alpha = source[3]; + + target[0] = unpremultiplyComponent (source[0], alpha); + target[1] = unpremultiplyComponent (source[1], alpha); + target[2] = unpremultiplyComponent (source[2], alpha); + target[3] = alpha; + + source += 4; + target += 4; + } + + return std::unique_ptr (destination.release()); +} +} // namespace + +//============================================================================== +void BitmapData::setPixelColor (int x, int y, Color color) +{ + setPixel (x, y, color.getARGB()); +} + +Color BitmapData::getPixelColor (int x, int y) const +{ + return Color (getPixel (x, y)); +} + +void BitmapData::fillColor (Color color) +{ + fill (color.getARGB()); +} + +//============================================================================== Image::Image (int w, int h, PixelFormat fmt) : bitmapData (new BitmapData (w, h, fmt)) { @@ -89,25 +157,44 @@ int Image::getPixelStride() const noexcept } //============================================================================== -void Image::setPixel (int x, int y, uint32_t color) +void Image::setPixel (int x, int y, uint32 color) { jassert (bitmapData != nullptr); bitmapData->setPixel (x, y, color); } -uint32_t Image::getPixel (int x, int y) const +void Image::setPixelColor (int x, int y, Color color) +{ + jassert (bitmapData != nullptr); + + bitmapData->setPixelColor (x, y, color); +} + +uint32 Image::getPixel (int x, int y) const { jassert (bitmapData != nullptr); return bitmapData->getPixel (x, y); } -void Image::fill (uint32_t color) +Color Image::getPixelColor (int x, int y) const +{ + jassert (bitmapData != nullptr); + + return bitmapData->getPixelColor (x, y); +} + +void Image::fill (uint32 color) { bitmapData->fill (color); } +void Image::fillColor (Color color) +{ + bitmapData->fillColor (color); +} + void Image::clear() { bitmapData->clear(); @@ -169,16 +256,33 @@ bool Image::createTextureIfNotPresent (GraphicsContext& context) const if (renderContext == nullptr || renderContext->impl() == nullptr) return false; + std::vector texturePixels (static_cast (width) * static_cast (height) * 4u); + auto* destination = texturePixels.data(); + + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + writeARGBAsRGBAPremultiplied (bitmapData->getPixel (x, y), destination); + destination += 4; + } + } + texture = renderContext->impl()->makeImageTexture ( width, height, rive::math::msb (width | height), rive::GPUTextureFormat::rgba32, - getRawData().data()); + texturePixels.data()); return true; } +void Image::invalidateTexture() +{ + texture = nullptr; +} + rive::rcp Image::getTexture() const { return texture; @@ -194,13 +298,15 @@ ResultValue Image::loadFromData (Span imageData) Image result; - result.bitmapData = new BitmapData ( - bitmap->width(), - bitmap->height(), - (bitmap->pixelFormat() == rive::Bitmap::PixelFormat::RGB) - ? PixelFormat::RGB - : PixelFormat::RGBA, - bitmap->detachBytes()); + const auto pixelFormat = bitmap->pixelFormat(); + const auto imagePixelFormat = pixelFormat == rive::Bitmap::PixelFormat::RGB + ? PixelFormat::RGB + : PixelFormat::RGBA; + auto pixelData = pixelFormat == rive::Bitmap::PixelFormat::RGBAPremul + ? copyRGBAPremultipliedAsRGBA (*bitmap) + : bitmap->detachBytes(); + + result.bitmapData = new BitmapData (bitmap->width(), bitmap->height(), imagePixelFormat, std::move (pixelData)); return makeResultValueOk (result); } diff --git a/modules/yup_graphics/imaging/yup_Image.h b/modules/yup_graphics/imaging/yup_Image.h index 2fcabb1a9..562b93823 100644 --- a/modules/yup_graphics/imaging/yup_Image.h +++ b/modules/yup_graphics/imaging/yup_Image.h @@ -22,15 +22,16 @@ namespace yup { +class Color; class GraphicsContext; //============================================================================== -/** Supported pixel formats. */ +/** Supported raw pixel byte formats. */ enum class PixelFormat { - Grayscale, /**< 8-bit grayscale. */ - RGB, /**< 24-bit RGB. */ - RGBA /**< 32-bit RGBA. */ + Grayscale, /**< 8-bit grayscale luminance. */ + RGB, /**< 24-bit red, green, blue byte order. */ + RGBA /**< 32-bit red, green, blue, alpha byte order. */ }; //============================================================================== @@ -63,7 +64,10 @@ class YUP_API BitmapData : public ReferenceCountedObject if (w <= 0 || h <= 0) throw std::invalid_argument ("Width and Height must be positive integers."); - pixelBuffer = std::make_unique (getTotalSizeBytes()); + pixelStride = getBytesPerPixel (fmt); + lineStride = static_cast (w) * static_cast (pixelStride); + totalSizeBytes = static_cast (h) * lineStride; + pixelBuffer = std::make_unique (totalSizeBytes); } BitmapData (int w, int h, PixelFormat fmt, std::unique_ptr pixelData) @@ -74,6 +78,9 @@ class YUP_API BitmapData : public ReferenceCountedObject if (w <= 0 || h <= 0) throw std::invalid_argument ("Width and Height must be positive integers."); + pixelStride = getBytesPerPixel (fmt); + lineStride = static_cast (w) * static_cast (pixelStride); + totalSizeBytes = static_cast (h) * lineStride; pixelBuffer = std::unique_ptr (const_cast (pixelData.release())); } @@ -85,6 +92,9 @@ class YUP_API BitmapData : public ReferenceCountedObject : width (std::exchange (other.width, 0)) , height (std::exchange (other.height, 0)) , format (other.format) + , pixelStride (std::exchange (other.pixelStride, 0)) + , lineStride (std::exchange (other.lineStride, 0)) + , totalSizeBytes (std::exchange (other.totalSizeBytes, 0)) , pixelBuffer (std::exchange (other.pixelBuffer, {})) { } @@ -100,6 +110,9 @@ class YUP_API BitmapData : public ReferenceCountedObject width = std::exchange (other.width, 0); height = std::exchange (other.height, 0); format = other.format; + pixelStride = std::exchange (other.pixelStride, 0); + lineStride = std::exchange (other.lineStride, 0); + totalSizeBytes = std::exchange (other.totalSizeBytes, 0); pixelBuffer = std::exchange (other.pixelBuffer, {}); } @@ -131,38 +144,39 @@ class YUP_API BitmapData : public ReferenceCountedObject /** Returns the pixel stride. */ int getPixelStride() const noexcept { - return static_cast (getBytesPerPixel (format)); + return pixelStride; } //============================================================================== - /** Sets the pixel at (x, y) with the specified color. + /** Sets the pixel at (x, y) with the specified ARGB color. + + PixelFormat controls the raw byte layout used in storage. The color + value passed here is always packed as `0xAARRGGBB`. + @param x The x-coordinate of the pixel. @param y The y-coordinate of the pixel. - @param color The color value to set. + @param color The ARGB color value to set. */ - void setPixel (int x, int y, uint32_t color) + void setPixel (int x, int y, uint32 color) { validateCoordinates (x, y); - size_t index = (y * width + x) * getBytesPerPixel (format); + auto* pixel = getPixelPointerUnchecked (x, y); switch (format) { case PixelFormat::Grayscale: - pixelBuffer[index] = static_cast (color & 0xFF); + pixel[0] = argbToLuminance (color); break; case PixelFormat::RGB: - pixelBuffer[index] = static_cast ((color >> 16) & 0xFF); // R - pixelBuffer[index + 1] = static_cast ((color >> 8) & 0xFF); // G - pixelBuffer[index + 2] = static_cast (color & 0xFF); // B + pixel[0] = getRedFromARGB (color); + pixel[1] = getGreenFromARGB (color); + pixel[2] = getBlueFromARGB (color); break; case PixelFormat::RGBA: - pixelBuffer[index] = static_cast ((color >> 24) & 0xFF); // R - pixelBuffer[index + 1] = static_cast ((color >> 16) & 0xFF); // G - pixelBuffer[index + 2] = static_cast ((color >> 8) & 0xFF); // B - pixelBuffer[index + 3] = static_cast (color & 0xFF); // A + writeARGBAsRGBA (color, pixel); break; default: @@ -170,65 +184,84 @@ class YUP_API BitmapData : public ReferenceCountedObject } } - /** Gets the pixel color at (x, y). + /** Sets the pixel at (x, y) with the specified color. */ + void setPixelColor (int x, int y, Color color); + + /** Gets the pixel color at (x, y) as an ARGB value. + + PixelFormat controls the raw byte layout used in storage. The returned + color value is always packed as `0xAARRGGBB`. + @param x The x-coordinate of the pixel. @param y The y-coordinate of the pixel. - @return The color value of the pixel. + @return The ARGB color value of the pixel. */ - uint32_t getPixel (int x, int y) const + uint32 getPixel (int x, int y) const { validateCoordinates (x, y); - size_t index = (y * width + x) * getBytesPerPixel (format); - uint32_t color = 0; + const auto* pixel = getPixelPointerUnchecked (x, y); switch (format) { case PixelFormat::Grayscale: - return pixelBuffer[index]; + { + const auto value = static_cast (pixel[0]); + return 0xff000000u | (value << 16) | (value << 8) | value; + } case PixelFormat::RGB: - return (pixelBuffer[index] << 16) | (pixelBuffer[index + 1] << 8) | pixelBuffer[index + 2]; + return 0xff000000u + | (static_cast (pixel[0]) << 16) + | (static_cast (pixel[1]) << 8) + | static_cast (pixel[2]); case PixelFormat::RGBA: - return (pixelBuffer[index] << 24) | (pixelBuffer[index + 1] << 16) | (pixelBuffer[index + 2] << 8) | pixelBuffer[index + 3]; + return readRGBAAsARGB (pixel); default: throw std::runtime_error ("Unsupported pixel format."); } - - return 0; } - /** Fills the entire bitmap with the specified color. - @param color The color value to fill the bitmap with. + /** Gets the pixel color at (x, y). */ + Color getPixelColor (int x, int y) const; + + /** Fills the entire bitmap with the specified ARGB color. + + @param color The ARGB color value to fill the bitmap with. */ - void fill (uint32_t color) + void fill (uint32 color) { - size_t totalBytes = getTotalSizeBytes(); + if (totalSizeBytes == 0) + return; switch (format) { case PixelFormat::Grayscale: { - uint8 gray = static_cast (color & 0xFF); + uint8 gray = argbToLuminance (color); - std::memset (pixelBuffer.get(), gray, totalBytes); + std::memset (pixelBuffer.get(), gray, totalSizeBytes); break; } case PixelFormat::RGB: { - uint8 r = static_cast ((color >> 16) & 0xFF); - uint8 g = static_cast ((color >> 8) & 0xFF); - uint8 b = static_cast (color & 0xFF); + const uint8 r = getRedFromARGB (color); + const uint8 g = getGreenFromARGB (color); + const uint8 b = getBlueFromARGB (color); - for (int i = 0; i < width * height; ++i) + const auto numPixels = static_cast (width) * static_cast (height); + auto* pixel = pixelBuffer.get(); + + for (size_t i = 0; i < numPixels; ++i) { - pixelBuffer[i * 3] = r; - pixelBuffer[i * 3 + 1] = g; - pixelBuffer[i * 3 + 2] = b; + pixel[0] = r; + pixel[1] = g; + pixel[2] = b; + pixel += 3; } break; @@ -236,17 +269,14 @@ class YUP_API BitmapData : public ReferenceCountedObject case PixelFormat::RGBA: { - uint8 r = static_cast ((color >> 24) & 0xFF); - uint8 g = static_cast ((color >> 16) & 0xFF); - uint8 b = static_cast ((color >> 8) & 0xFF); - uint8 a = static_cast (color & 0xFF); + const auto numPixels = static_cast (width) * static_cast (height); + const auto rgba = packARGBAsRGBABytes (color); + auto* pixel = pixelBuffer.get(); - for (int i = 0; i < width * height; ++i) + for (size_t i = 0; i < numPixels; ++i) { - pixelBuffer[i * 4] = r; - pixelBuffer[i * 4 + 1] = g; - pixelBuffer[i * 4 + 2] = b; - pixelBuffer[i * 4 + 3] = a; + std::memcpy (pixel, &rgba, sizeof (rgba)); + pixel += 4; } break; @@ -257,28 +287,34 @@ class YUP_API BitmapData : public ReferenceCountedObject } } + /** Fills the entire bitmap with the specified color. */ + void fillColor (Color color); + /** Clears the bitmap by setting all pixels to zero. */ void clear() { - std::fill (pixelBuffer.get(), pixelBuffer.get() + getTotalSizeBytes(), 0); + if (totalSizeBytes == 0) + return; + + std::fill (pixelBuffer.get(), pixelBuffer.get() + totalSizeBytes, 0); } /** Returns a pointer to the raw pixel data. */ Span getRawData() const noexcept { - return { pixelBuffer.get(), getTotalSizeBytes() }; + return { pixelBuffer.get(), totalSizeBytes }; } /** Returns a mutable pointer to the raw pixel data. */ Span getRawData() noexcept { - return { pixelBuffer.get(), getTotalSizeBytes() }; + return { pixelBuffer.get(), totalSizeBytes }; } private: //============================================================================== /** Returns the number of bytes per pixel for the given format. */ - static size_t getBytesPerPixel (PixelFormat fmt) + static int getBytesPerPixel (PixelFormat fmt) { switch (fmt) { @@ -296,21 +332,93 @@ class YUP_API BitmapData : public ReferenceCountedObject } } - size_t getTotalSizeBytes() const + static uint8 getAlphaFromARGB (uint32 color) noexcept + { + return static_cast ((color >> 24) & 0xFF); + } + + static uint8 getRedFromARGB (uint32 color) noexcept + { + return static_cast ((color >> 16) & 0xFF); + } + + static uint8 getGreenFromARGB (uint32 color) noexcept + { + return static_cast ((color >> 8) & 0xFF); + } + + static uint8 getBlueFromARGB (uint32 color) noexcept + { + return static_cast (color & 0xFF); + } + + static uint8 argbToLuminance (uint32 color) noexcept + { + const auto red = static_cast (getRedFromARGB (color)); + const auto green = static_cast (getGreenFromARGB (color)); + const auto blue = static_cast (getBlueFromARGB (color)); + + return static_cast ((red * 54u + green * 183u + blue * 19u + 128u) >> 8); + } + + static uint32 packARGBAsRGBABytes (uint32 color) noexcept { - return width * height * getBytesPerPixel (format); + return ByteOrder::swapIfBigEndian (ByteOrder::makeInt ( + getRedFromARGB (color), + getGreenFromARGB (color), + getBlueFromARGB (color), + getAlphaFromARGB (color))); + } + + static uint32 unpackRGBABytesAsARGB (uint32 rgba) noexcept + { + return (rgba & 0xff000000u) + | ((rgba & 0x000000ffu) << 16) + | (rgba & 0x0000ff00u) + | ((rgba & 0x00ff0000u) >> 16); + } + + static void writeARGBAsRGBA (uint32 color, uint8* pixel) noexcept + { + const auto rgba = packARGBAsRGBABytes (color); + std::memcpy (pixel, &rgba, sizeof (rgba)); + } + + static uint32 readRGBAAsARGB (const uint8* pixel) noexcept + { + return unpackRGBABytesAsARGB (ByteOrder::littleEndianInt (pixel)); + } + + uint8* getPixelPointerUnchecked (int x, int y) noexcept + { + return pixelBuffer.get() + + static_cast (y) * lineStride + + static_cast (x) * static_cast (pixelStride); + } + + const uint8* getPixelPointerUnchecked (int x, int y) const noexcept + { + return pixelBuffer.get() + + static_cast (y) * lineStride + + static_cast (x) * static_cast (pixelStride); } /** Validates the (x, y) coordinates. */ void validateCoordinates (int x, int y) const { - if (x < 0 || x >= width || y < 0 || y >= height) + if (static_cast (x) >= static_cast (width) + || static_cast (y) >= static_cast (height)) + { throw std::out_of_range ("Pixel coordinates out of range."); + } } int width = 0; int height = 0; - PixelFormat format = PixelFormat::RGB; + PixelFormat format = PixelFormat::RGBA; + int pixelStride = 4; + size_t lineStride = 0; + size_t totalSizeBytes = 0; std::unique_ptr pixelBuffer; }; @@ -352,6 +460,7 @@ class Image ~Image() = default; //============================================================================== + /** Returns true if the image contains valid bitmap data. */ bool isValid() const noexcept; //============================================================================== @@ -368,24 +477,39 @@ class Image int getPixelStride() const noexcept; //============================================================================== - /** Sets the pixel at (x, y) with the specified color. + /** Sets the pixel at (x, y) with the specified ARGB color. + + PixelFormat controls the raw byte layout used in storage. The color + value passed here is always packed as `0xAARRGGBB`. + @param x The x-coordinate of the pixel. @param y The y-coordinate of the pixel. - @param color The color value to set. + @param color The ARGB color value to set. */ - void setPixel (int x, int y, uint32_t color); + void setPixel (int x, int y, uint32 color); + + /** Sets the pixel at (x, y) with the specified color. */ + void setPixelColor (int x, int y, Color color); + + /** Gets the pixel color at (x, y) as an ARGB value. - /** Gets the pixel color at (x, y). @param x The x-coordinate of the pixel. @param y The y-coordinate of the pixel. - @return The color value of the pixel. + @return The ARGB color value of the pixel. */ - uint32_t getPixel (int x, int y) const; + uint32 getPixel (int x, int y) const; + + /** Gets the pixel color at (x, y). */ + Color getPixelColor (int x, int y) const; - /** Fills the entire image with the specified color. - @param color The color value to fill the image with. + /** Fills the entire image with the specified ARGB color. + + @param color The ARGB color value to fill the image with. */ - void fill (uint32_t color); + void fill (uint32 color); + + /** Fills the entire image with the specified color. */ + void fillColor (Color color); /** Clears the image by setting all pixels to zero. */ void clear(); @@ -407,12 +531,26 @@ class Image Image duplicate() const; */ + //============================================================================== + /** Creates a texture on the GPU for the image if it doesn't already exist. + + @param context The graphics context to use for texture creation. + @return True if the texture was created or already exists, false otherwise. + */ bool createTextureIfNotPresent (GraphicsContext& context) const; + /** Invalidates the existing texture, forcing a new texture to be created on the next request. */ + void invalidateTexture(); + + /** Returns the GPU texture associated with this image, or nullptr if no texture exists. */ rive::rcp getTexture() const; //============================================================================== + /** Loads an image from raw data. + @param imageData The raw image data. + @return A ResultValue containing the loaded image or an error message. + */ static ResultValue loadFromData (Span imageData); private: diff --git a/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp b/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp index 42dcaf533..648c06a8f 100644 --- a/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupGraphics_bindings.cpp @@ -1086,13 +1086,17 @@ void registerYupGraphicsBindings (py::module_& m) .def_static ("fromHSL", &Color::fromHSL) .def_static ("fromString", &Color::fromString) .def_static ("fromRGB", &Color::fromRGB) - .def_static ("fromRGBA", &Color::fromRGBA) + .def_static ("fromRGBA", [] (uint8 r, uint8 g, uint8 b, uint8 a) { return Color::fromRGBA (r, g, b, a); }) + .def_static ("fromPackedRGBA", [] (uint32 colorRGBA) { return Color::fromRGBA (colorRGBA); }) .def_static ("fromARGB", &Color::fromARGB) - .def_static ("fromBGRA", &Color::fromBGRA) + .def_static ("fromBGRA", [] (uint8 b, uint8 g, uint8 r, uint8 a) { return Color::fromBGRA (b, g, r, a); }) + .def_static ("fromPackedBGRA", [] (uint32 colorBGRA) { return Color::fromBGRA (colorBGRA); }) .def_static ("opaqueRandom", &Color::opaqueRandom) // Color data access .def ("getARGB", &Color::getARGB) + .def ("getRGBA", &Color::getRGBA) + .def ("getBGRA", &Color::getBGRA) // .def (int() (py::self)) // implicit conversion to uint32 // Transparency checks diff --git a/tests/yup_graphics/yup_Color.cpp b/tests/yup_graphics/yup_Color.cpp index 39a422d8b..01e8ebd4d 100644 --- a/tests/yup_graphics/yup_Color.cpp +++ b/tests/yup_graphics/yup_Color.cpp @@ -111,6 +111,15 @@ TEST (ColorTests, Uint32_Constructor) EXPECT_TRUE (c.isSemiTransparent()); } +TEST (ColorTests, PackedByteOrderAccessors) +{ + const Color c (0x80123456); + + EXPECT_EQ (c.getARGB(), 0x80123456u); + EXPECT_EQ (c.getRGBA(), 0x12345680u); + EXPECT_EQ (c.getBGRA(), 0x56341280u); +} + TEST (ColorTests, RGB_Constructor) { Color c (255, 128, 64); @@ -131,6 +140,24 @@ TEST (ColorTests, ARGB_Constructor) EXPECT_TRUE (c.isSemiTransparent()); } +TEST (ColorTests, ComponentFactoriesCreateExpectedPackedColors) +{ + EXPECT_EQ (Color::fromRGB (0x12, 0x34, 0x56).getARGB(), 0xff123456u); + EXPECT_EQ (Color::fromARGB (0x80, 0x12, 0x34, 0x56).getARGB(), 0x80123456u); + EXPECT_EQ (Color::fromRGBA (0x12, 0x34, 0x56, 0x80).getARGB(), 0x80123456u); + EXPECT_EQ (Color::fromBGRA (0x56, 0x34, 0x12, 0x80).getARGB(), 0x80123456u); +} + +TEST (ColorTests, PackedFactoriesCreateExpectedColors) +{ + EXPECT_EQ (Color::fromRGBA (0x12345680).getARGB(), 0x80123456u); + EXPECT_EQ (Color::fromBGRA (0x56341280).getARGB(), 0x80123456u); + + const Color transparent = Color::fromRGBA (0x12345600); + EXPECT_EQ (transparent.getARGB(), 0x00123456u); + EXPECT_TRUE (transparent.isTransparent()); +} + TEST (ColorTests, Copy_And_Move_Constructors) { Color c1 (0xff123456); diff --git a/tests/yup_graphics/yup_ColorGradient.cpp b/tests/yup_graphics/yup_ColorGradient.cpp index 43ad0baa6..e77e146d0 100644 --- a/tests/yup_graphics/yup_ColorGradient.cpp +++ b/tests/yup_graphics/yup_ColorGradient.cpp @@ -23,6 +23,7 @@ #include +#include #include #include @@ -745,3 +746,42 @@ TEST (ColorGradientTests, GetColorAt_XY_Radial_ZeroRadius) EXPECT_EQ (gradient.getColorAt (10.0f, 10.0f).getARGB(), start.getARGB()); } + +TEST (ColorGradientTests, FillGradient_FillsProvidedSpan) +{ + const Color start (0xffff0000); + const Color end (0x800000ff); + const ColorGradient gradient (ColorGradient::Linear, + { + ColorGradient::ColorStop (start, 0.0f, 0.0f, 0.0f), + ColorGradient::ColorStop (end, 10.0f, 0.0f, 1.0f), + }); + + std::array colors {}; + gradient.fillGradient (colors); + + EXPECT_EQ (colors[0], start.getARGB()); + EXPECT_EQ (colors[1], start.mixedWith (end, 0.5f, ColorSpace::SRGB).getARGB()); + EXPECT_EQ (colors[2], end.getARGB()); +} + +TEST (ColorGradientTests, FillGradient_HandlesEmptyAndSingleStopGradients) +{ + std::array emptyColors {}; + ColorGradient().fillGradient (emptyColors); + EXPECT_EQ (emptyColors[0], Color().getARGB()); + EXPECT_EQ (emptyColors[1], Color().getARGB()); + + const Color only (0xff123456); + const ColorGradient single (ColorGradient::Linear, + { + ColorGradient::ColorStop (only, 4.0f, 5.0f, 0.3f), + }); + + std::array singleColors {}; + single.fillGradient (singleColors); + + EXPECT_EQ (singleColors[0], only.getARGB()); + EXPECT_EQ (singleColors[1], only.getARGB()); + EXPECT_EQ (singleColors[2], only.getARGB()); +} diff --git a/tests/yup_graphics/yup_Image.cpp b/tests/yup_graphics/yup_Image.cpp new file mode 100644 index 000000000..10583bead --- /dev/null +++ b/tests/yup_graphics/yup_Image.cpp @@ -0,0 +1,228 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +TEST (ImageTests, RgbaBitmapStoresRGBABytesAndReturnsARGBColor) +{ + Image image (1, 1, PixelFormat::RGBA); + + image.setPixel (0, 0, 0x80123456); + + const auto raw = image.getRawData(); + + ASSERT_EQ (raw.size(), 4u); + EXPECT_EQ (raw[0], 0x12); + EXPECT_EQ (raw[1], 0x34); + EXPECT_EQ (raw[2], 0x56); + EXPECT_EQ (raw[3], 0x80); + EXPECT_EQ (image.getPixel (0, 0), 0x80123456u); + EXPECT_EQ (image.getPixelColor (0, 0), Color (0x80123456)); +} + +TEST (ImageTests, RgbBitmapStoresRGBBytesAndReturnsOpaqueARGBColor) +{ + Image image (1, 1, PixelFormat::RGB); + + image.setPixelColor (0, 0, Color (0x80123456)); + + const auto raw = image.getRawData(); + + ASSERT_EQ (raw.size(), 3u); + EXPECT_EQ (raw[0], 0x12); + EXPECT_EQ (raw[1], 0x34); + EXPECT_EQ (raw[2], 0x56); + EXPECT_EQ (image.getPixel (0, 0), 0xff123456u); +} + +TEST (ImageTests, GrayscaleBitmapStoresLuminanceAndReturnsOpaqueARGBColor) +{ + Image image (1, 1, PixelFormat::Grayscale); + + image.setPixelColor (0, 0, Color (0xffff0000)); + + const auto raw = image.getRawData(); + + ASSERT_EQ (raw.size(), 1u); + EXPECT_EQ (raw[0], 54); + EXPECT_EQ (image.getPixel (0, 0), 0xff363636u); +} + +TEST (ImageTests, ColorCanConvertToExplicitPackedByteOrders) +{ + const Color color (0x80123456); + + EXPECT_EQ (color.getARGB(), 0x80123456u); + EXPECT_EQ (color.getRGBA(), 0x12345680u); + EXPECT_EQ (color.getBGRA(), 0x56341280u); + EXPECT_EQ (Color::fromRGBA (0x12345680), color); + EXPECT_EQ (Color::fromBGRA (0x56341280), color); +} + +TEST (BitmapDataTests, RgbaSetPixelWritesAtCorrectRowOffset) +{ + BitmapData bitmap (2, 2, PixelFormat::RGBA); + bitmap.clear(); + + bitmap.setPixel (1, 1, 0x80123456); + + const auto raw = bitmap.getRawData(); + + ASSERT_EQ (raw.size(), 16u); + EXPECT_EQ (raw[12], 0x12); + EXPECT_EQ (raw[13], 0x34); + EXPECT_EQ (raw[14], 0x56); + EXPECT_EQ (raw[15], 0x80); + EXPECT_EQ (bitmap.getPixel (1, 1), 0x80123456u); +} + +TEST (BitmapDataTests, FillWritesExpectedBytesForRGBA) +{ + BitmapData bitmap (2, 2, PixelFormat::RGBA); + + bitmap.fill (0x80123456); + + const auto raw = bitmap.getRawData(); + + ASSERT_EQ (raw.size(), 16u); + for (size_t i = 0; i < raw.size(); i += 4) + { + EXPECT_EQ (raw[i], 0x12); + EXPECT_EQ (raw[i + 1], 0x34); + EXPECT_EQ (raw[i + 2], 0x56); + EXPECT_EQ (raw[i + 3], 0x80); + } +} + +TEST (BitmapDataTests, FillWritesExpectedBytesForRGB) +{ + BitmapData bitmap (2, 1, PixelFormat::RGB); + + bitmap.fill (0x80123456); + + const auto raw = bitmap.getRawData(); + + ASSERT_EQ (raw.size(), 6u); + EXPECT_EQ (raw[0], 0x12); + EXPECT_EQ (raw[1], 0x34); + EXPECT_EQ (raw[2], 0x56); + EXPECT_EQ (raw[3], 0x12); + EXPECT_EQ (raw[4], 0x34); + EXPECT_EQ (raw[5], 0x56); +} + +TEST (BitmapDataTests, ClearZerosRawData) +{ + BitmapData bitmap (2, 2, PixelFormat::RGBA); + + bitmap.fill (0xffffffff); + bitmap.clear(); + + const auto raw = bitmap.getRawData(); + + ASSERT_EQ (raw.size(), 16u); + for (const auto byte : raw) + EXPECT_EQ (byte, 0); +} + +TEST (BitmapDataTests, MoveConstructorPreservesPixelDataAndStrides) +{ + BitmapData original (2, 2, PixelFormat::RGBA); + original.setPixel (1, 1, 0x80123456); + + BitmapData moved (std::move (original)); + + EXPECT_EQ (moved.getWidth(), 2); + EXPECT_EQ (moved.getHeight(), 2); + EXPECT_EQ (moved.getPixelStride(), 4); + EXPECT_EQ (moved.getRawData().size(), 16u); + EXPECT_EQ (moved.getPixel (1, 1), 0x80123456u); + + EXPECT_EQ (original.getWidth(), 0); + EXPECT_EQ (original.getHeight(), 0); + EXPECT_EQ (original.getRawData().size(), 0u); +} + +TEST (BitmapDataTests, MoveAssignmentPreservesPixelDataAndStrides) +{ + BitmapData source (2, 1, PixelFormat::RGB); + source.setPixel (1, 0, 0x80123456); + + BitmapData target (1, 1, PixelFormat::Grayscale); + target = std::move (source); + + EXPECT_EQ (target.getWidth(), 2); + EXPECT_EQ (target.getHeight(), 1); + EXPECT_EQ (target.getPixelFormat(), PixelFormat::RGB); + EXPECT_EQ (target.getPixelStride(), 3); + EXPECT_EQ (target.getRawData().size(), 6u); + EXPECT_EQ (target.getPixel (1, 0), 0xff123456u); +} + +TEST (BitmapDataTests, ConstructorRejectsInvalidDimensions) +{ + EXPECT_THROW (BitmapData (0, 1, PixelFormat::RGBA), std::invalid_argument); + EXPECT_THROW (BitmapData (1, 0, PixelFormat::RGBA), std::invalid_argument); + EXPECT_THROW (BitmapData (-1, 1, PixelFormat::RGBA), std::invalid_argument); + EXPECT_THROW (BitmapData (1, -1, PixelFormat::RGBA), std::invalid_argument); +} + +TEST (BitmapDataTests, PixelAccessRejectsOutOfRangeCoordinates) +{ + BitmapData bitmap (2, 2, PixelFormat::RGBA); + + EXPECT_THROW (bitmap.setPixel (-1, 0, 0xffffffff), std::out_of_range); + EXPECT_THROW (bitmap.setPixel (0, -1, 0xffffffff), std::out_of_range); + EXPECT_THROW (bitmap.setPixel (2, 0, 0xffffffff), std::out_of_range); + EXPECT_THROW (bitmap.setPixel (0, 2, 0xffffffff), std::out_of_range); + EXPECT_THROW (bitmap.getPixel (2, 0), std::out_of_range); +} + +TEST (ImageTests, FillColorAndClearRoundTrip) +{ + Image image (2, 2, PixelFormat::RGBA); + + image.fillColor (Color (0x80123456)); + EXPECT_EQ (image.getPixelColor (0, 0), Color (0x80123456)); + EXPECT_EQ (image.getPixelColor (1, 1), Color (0x80123456)); + + image.clear(); + EXPECT_EQ (image.getPixel (0, 0), 0u); + EXPECT_EQ (image.getPixel (1, 1), 0u); +} + +TEST (ImageTests, MoveConstructorTransfersBitmapData) +{ + Image source (1, 1, PixelFormat::RGBA); + source.setPixel (0, 0, 0x80123456); + + Image moved (std::move (source)); + + EXPECT_FALSE (source.isValid()); + EXPECT_TRUE (moved.isValid()); + EXPECT_EQ (moved.getWidth(), 1); + EXPECT_EQ (moved.getHeight(), 1); + EXPECT_EQ (moved.getPixel (0, 0), 0x80123456u); +} From c895e4cfcd2326861760205e64a1e5381783bd50 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Mon, 25 May 2026 23:43:27 +0200 Subject: [PATCH 2/3] More tests --- .../displays/yup_SpectrogramComponent.h | 12 + tests/yup_audio_gui.cpp | 1 + .../yup_SpectrogramComponent.cpp | 394 ++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 tests/yup_audio_gui/yup_SpectrogramComponent.cpp diff --git a/modules/yup_audio_gui/displays/yup_SpectrogramComponent.h b/modules/yup_audio_gui/displays/yup_SpectrogramComponent.h index 3a0451367..a6e9dbdf2 100644 --- a/modules/yup_audio_gui/displays/yup_SpectrogramComponent.h +++ b/modules/yup_audio_gui/displays/yup_SpectrogramComponent.h @@ -240,6 +240,18 @@ class YUP_API SpectrogramComponent /** Clears the spectrogram history, resetting the display. */ void clearHistory(); + //============================================================================== + /** Returns the current spectrogram image. + + The returned image contains the waterfall history used by paint() when + rendering the component. The image may be invalid until the spectrogram + has allocated its history buffer, for example after setNumHistoryFrames() + or after processing FFT data on a visible component. + + @returns the current spectrogram image. + */ + Image getSpectrogramImage() const noexcept { return spectrogramImage; } + //============================================================================== /** @internal */ void paint (Graphics& g) override; diff --git a/tests/yup_audio_gui.cpp b/tests/yup_audio_gui.cpp index 0538b6de5..85a29be43 100644 --- a/tests/yup_audio_gui.cpp +++ b/tests/yup_audio_gui.cpp @@ -27,3 +27,4 @@ #include "yup_audio_gui/yup_CartesianPlane.cpp" #include "yup_audio_gui/yup_KMeterComponent.cpp" #include "yup_audio_gui/yup_SpectrumAnalyzerComponent.cpp" +#include "yup_audio_gui/yup_SpectrogramComponent.cpp" diff --git a/tests/yup_audio_gui/yup_SpectrogramComponent.cpp b/tests/yup_audio_gui/yup_SpectrogramComponent.cpp new file mode 100644 index 000000000..4508b235b --- /dev/null +++ b/tests/yup_audio_gui/yup_SpectrogramComponent.cpp @@ -0,0 +1,394 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include + +using namespace yup; + +namespace yup +{ +extern std::unique_ptr yup_constructHeadlessGraphicsContext (yup::GraphicsContext::Options); +} // namespace yup + +namespace +{ + +uint8 getAlpha (uint32 color) noexcept +{ + return static_cast ((color >> 24) & 0xff); +} + +std::vector createSineBuffer (int numSamples, float period) +{ + std::vector buffer (static_cast (numSamples)); + + for (int i = 0; i < numSamples; ++i) + buffer[static_cast (i)] = std::sin (2.0f * MathConstants::pi * static_cast (i) / period); + + return buffer; +} + +} // namespace + +class SpectrogramComponentTests : public ::testing::Test +{ +protected: + void SetUp() override + { + mm = MessageManager::getInstance(); + state = std::make_unique (2048); + spectrogram = std::make_unique (*state); + spectrogram->setBounds (0.0f, 0.0f, 800.0f, 400.0f); + } + + void TearDown() override + { + spectrogram.reset(); + state.reset(); + } + + MessageManager* mm = nullptr; + std::unique_ptr state; + std::unique_ptr spectrogram; +}; + +//============================================================================== +// Color Map Tests +//============================================================================== + +TEST (SpectrogramColorMapTests, GrayscaleMapsEndpointsAndClampsInput) +{ + SpectrogramColorMap colorMap (SpectrogramColorMap::Type::grayscale, 16); + + EXPECT_EQ (16, colorMap.getNumColorStops()); + EXPECT_EQ (0xff000000u, colorMap.map (-1.0f)); + EXPECT_EQ (0xff000000u, colorMap.map (0.0f)); + EXPECT_EQ (0xffffffffu, colorMap.map (1.0f)); + EXPECT_EQ (0xffffffffu, colorMap.map (2.0f)); + EXPECT_NE (colorMap.map (0.25f), colorMap.map (0.75f)); +} + +TEST (SpectrogramColorMapTests, ConstructorUsesAtLeastTwoColorStops) +{ + SpectrogramColorMap colorMap (SpectrogramColorMap::Type::heatmap, 1); + + EXPECT_EQ (2, colorMap.getNumColorStops()); + EXPECT_NE (colorMap.map (0.0f), colorMap.map (1.0f)); +} + +TEST (SpectrogramColorMapTests, PredefinedColorMapsReturnOpaqueColors) +{ + const SpectrogramColorMap::Type types[] = { + SpectrogramColorMap::Type::heatmap, + SpectrogramColorMap::Type::grayscale, + SpectrogramColorMap::Type::cool, + SpectrogramColorMap::Type::warm, + SpectrogramColorMap::Type::viridis, + }; + + for (const auto type : types) + { + SpectrogramColorMap colorMap (type); + + EXPECT_EQ (256, colorMap.getNumColorStops()); + EXPECT_EQ (0xff, getAlpha (colorMap.map (0.0f))); + EXPECT_EQ (0xff, getAlpha (colorMap.map (0.5f))); + EXPECT_EQ (0xff, getAlpha (colorMap.map (1.0f))); + EXPECT_NE (colorMap.map (0.0f), colorMap.map (1.0f)); + } +} + +//============================================================================== +// Construction Tests +//============================================================================== + +TEST_F (SpectrogramComponentTests, ConstructorInitializesDefaults) +{ + EXPECT_EQ (2048, spectrogram->getFFTSize()); + EXPECT_EQ (WindowType::hann, spectrogram->getWindowType()); + EXPECT_EQ (25, spectrogram->getUpdateRate()); + EXPECT_FLOAT_EQ (20.0f, spectrogram->getMinFrequency()); + EXPECT_FLOAT_EQ (20000.0f, spectrogram->getMaxFrequency()); + EXPECT_FLOAT_EQ (-100.0f, spectrogram->getMinDecibels()); + EXPECT_FLOAT_EQ (0.0f, spectrogram->getMaxDecibels()); + EXPECT_DOUBLE_EQ (44100.0, spectrogram->getSampleRate()); + EXPECT_EQ (256, spectrogram->getNumHistoryFrames()); + EXPECT_FLOAT_EQ (0.75f, spectrogram->getOverlapFactor()); + EXPECT_EQ (256, spectrogram->getColorMap().getNumColorStops()); + EXPECT_FALSE (spectrogram->getSpectrogramImage().isValid()); +} + +TEST_F (SpectrogramComponentTests, ConstructorUsesAnalyzerStateFFTSize) +{ + SpectrumAnalyzerState state4096 (4096); + SpectrogramComponent spectrogram4096 (state4096); + + EXPECT_EQ (4096, spectrogram4096.getFFTSize()); +} + +//============================================================================== +// Configuration Tests +//============================================================================== + +TEST_F (SpectrogramComponentTests, SetFFTSizeUpdatesComponentAndState) +{ + spectrogram->setFFTSize (4096); + + EXPECT_EQ (4096, spectrogram->getFFTSize()); + EXPECT_EQ (4096, state->getFftSize()); + + spectrogram->setFFTSize (1024); + + EXPECT_EQ (1024, spectrogram->getFFTSize()); + EXPECT_EQ (1024, state->getFftSize()); +} + +TEST_F (SpectrogramComponentTests, SetWindowTypeUpdatesCurrentWindow) +{ + spectrogram->setWindowType (WindowType::blackmanHarris); + EXPECT_EQ (WindowType::blackmanHarris, spectrogram->getWindowType()); + + spectrogram->setWindowType (WindowType::rectangular); + EXPECT_EQ (WindowType::rectangular, spectrogram->getWindowType()); +} + +TEST_F (SpectrogramComponentTests, SetUpdateRateClampsToSupportedRange) +{ + spectrogram->setUpdateRate (30); + EXPECT_EQ (30, spectrogram->getUpdateRate()); + + spectrogram->setUpdateRate (0); + EXPECT_EQ (1, spectrogram->getUpdateRate()); + + spectrogram->setUpdateRate (-10); + EXPECT_EQ (1, spectrogram->getUpdateRate()); + + spectrogram->setUpdateRate (1000); + EXPECT_GE (spectrogram->getUpdateRate(), 60); + EXPECT_LE (spectrogram->getUpdateRate(), 63); +} + +TEST_F (SpectrogramComponentTests, SetFrequencyRangeUpdatesAndClampsValues) +{ + spectrogram->setFrequencyRange (100.0f, 5000.0f); + + EXPECT_FLOAT_EQ (100.0f, spectrogram->getMinFrequency()); + EXPECT_FLOAT_EQ (5000.0f, spectrogram->getMaxFrequency()); + + spectrogram->setFrequencyRange (-100.0f, 500.0f); + + EXPECT_FLOAT_EQ (1.0f, spectrogram->getMinFrequency()); + EXPECT_FLOAT_EQ (500.0f, spectrogram->getMaxFrequency()); + + spectrogram->setFrequencyRange (2000.0f, 100.0f); + + EXPECT_FLOAT_EQ (2000.0f, spectrogram->getMinFrequency()); + EXPECT_FLOAT_EQ (2001.0f, spectrogram->getMaxFrequency()); +} + +TEST_F (SpectrogramComponentTests, SetDecibelRangeUpdatesValues) +{ + spectrogram->setDecibelRange (-80.0f, 6.0f); + + EXPECT_FLOAT_EQ (-80.0f, spectrogram->getMinDecibels()); + EXPECT_FLOAT_EQ (6.0f, spectrogram->getMaxDecibels()); +} + +TEST_F (SpectrogramComponentTests, SetSampleRateUpdatesAndClampsValues) +{ + spectrogram->setSampleRate (48000.0); + EXPECT_DOUBLE_EQ (48000.0, spectrogram->getSampleRate()); + + spectrogram->setSampleRate (0.0); + EXPECT_DOUBLE_EQ (1.0, spectrogram->getSampleRate()); + + spectrogram->setSampleRate (-44100.0); + EXPECT_DOUBLE_EQ (1.0, spectrogram->getSampleRate()); +} + +TEST_F (SpectrogramComponentTests, SetColorMapReplacesCurrentMap) +{ + spectrogram->setColorMap (SpectrogramColorMap::Type::viridis); + + SpectrogramColorMap expected (SpectrogramColorMap::Type::viridis); + EXPECT_EQ (expected.map (0.0f), spectrogram->getColorMap().map (0.0f)); + EXPECT_EQ (expected.map (0.5f), spectrogram->getColorMap().map (0.5f)); + EXPECT_EQ (expected.map (1.0f), spectrogram->getColorMap().map (1.0f)); +} + +TEST_F (SpectrogramComponentTests, SetNumHistoryFramesUpdatesAndClampsToMinimum) +{ + spectrogram->setNumHistoryFrames (64); + + EXPECT_EQ (64, spectrogram->getNumHistoryFrames()); + ASSERT_TRUE (spectrogram->getSpectrogramImage().isValid()); + EXPECT_EQ (SpectrogramComponent::defaultSpectrogramWidth, spectrogram->getSpectrogramImage().getWidth()); + EXPECT_EQ (64, spectrogram->getSpectrogramImage().getHeight()); + + spectrogram->setNumHistoryFrames (1); + + EXPECT_EQ (4, spectrogram->getNumHistoryFrames()); + ASSERT_TRUE (spectrogram->getSpectrogramImage().isValid()); + EXPECT_EQ (SpectrogramComponent::defaultSpectrogramWidth, spectrogram->getSpectrogramImage().getWidth()); + EXPECT_EQ (4, spectrogram->getSpectrogramImage().getHeight()); +} + +TEST_F (SpectrogramComponentTests, GetSpectrogramImageReturnsCurrentHistoryImage) +{ + spectrogram->setNumHistoryFrames (8); + + const auto& image = spectrogram->getSpectrogramImage(); + + ASSERT_TRUE (image.isValid()); + EXPECT_EQ (SpectrogramComponent::defaultSpectrogramWidth, image.getWidth()); + EXPECT_EQ (8, image.getHeight()); + EXPECT_EQ (PixelFormat::RGBA, image.getPixelFormat()); +} + +TEST_F (SpectrogramComponentTests, SetOverlapFactorDelegatesToAnalyzerState) +{ + spectrogram->setOverlapFactor (0.5f); + + EXPECT_FLOAT_EQ (0.5f, spectrogram->getOverlapFactor()); + EXPECT_FLOAT_EQ (0.5f, state->getOverlapFactor()); + EXPECT_EQ (spectrogram->getFFTSize() / 2, state->getHopSize()); + + spectrogram->setOverlapFactor (0.0f); + + EXPECT_FLOAT_EQ (0.0f, spectrogram->getOverlapFactor()); + EXPECT_EQ (spectrogram->getFFTSize(), state->getHopSize()); +} + +//============================================================================== +// Runtime Tests +//============================================================================== + +TEST_F (SpectrogramComponentTests, TimerCallbackWithoutAudioDataDoesNotCrash) +{ + spectrogram->timerCallback(); + + EXPECT_TRUE (true); +} + +TEST_F (SpectrogramComponentTests, TimerCallbackWithAudioDataDoesNotCrash) +{ + const auto testData = createSineBuffer (2048, 100.0f); + state->pushSamples (testData.data(), static_cast (testData.size())); + + spectrogram->timerCallback(); + + EXPECT_TRUE (true); +} + +TEST_F (SpectrogramComponentTests, ClearHistoryDoesNotChangeConfiguration) +{ + spectrogram->setFrequencyRange (100.0f, 10000.0f); + spectrogram->setDecibelRange (-90.0f, -6.0f); + spectrogram->setSampleRate (48000.0); + spectrogram->setNumHistoryFrames (32); + + spectrogram->clearHistory(); + + EXPECT_FLOAT_EQ (100.0f, spectrogram->getMinFrequency()); + EXPECT_FLOAT_EQ (10000.0f, spectrogram->getMaxFrequency()); + EXPECT_FLOAT_EQ (-90.0f, spectrogram->getMinDecibels()); + EXPECT_FLOAT_EQ (-6.0f, spectrogram->getMaxDecibels()); + EXPECT_DOUBLE_EQ (48000.0, spectrogram->getSampleRate()); + EXPECT_EQ (32, spectrogram->getNumHistoryFrames()); + ASSERT_TRUE (spectrogram->getSpectrogramImage().isValid()); + EXPECT_EQ (SpectrogramComponent::defaultSpectrogramWidth, spectrogram->getSpectrogramImage().getWidth()); + EXPECT_EQ (32, spectrogram->getSpectrogramImage().getHeight()); +} + +TEST_F (SpectrogramComponentTests, PaintWithoutAudioDataDoesNotCrash) +{ + auto context = yup_constructHeadlessGraphicsContext ({}); + auto renderer = context->makeRenderer (800, 400); + Graphics g (*context, *renderer); + + spectrogram->paint (g); + + EXPECT_TRUE (true); +} + +TEST_F (SpectrogramComponentTests, PaintAfterTimerCallbackDoesNotCrash) +{ + const auto testData = createSineBuffer (2048, 100.0f); + state->pushSamples (testData.data(), static_cast (testData.size())); + spectrogram->timerCallback(); + + auto context = yup_constructHeadlessGraphicsContext ({}); + auto renderer = context->makeRenderer (800, 400); + Graphics g (*context, *renderer); + + spectrogram->paint (g); + + EXPECT_TRUE (true); +} + +TEST_F (SpectrogramComponentTests, ResizedDoesNotCrash) +{ + spectrogram->setBounds (0.0f, 0.0f, 1000.0f, 600.0f); + spectrogram->resized(); + + spectrogram->setBounds (0.0f, 0.0f, 0.0f, 0.0f); + spectrogram->resized(); + + EXPECT_TRUE (true); +} + +//============================================================================== +// Integration Tests +//============================================================================== + +TEST_F (SpectrogramComponentTests, CompleteWorkflow) +{ + spectrogram->setFFTSize (4096); + spectrogram->setWindowType (WindowType::hamming); + spectrogram->setUpdateRate (30); + spectrogram->setFrequencyRange (20.0f, 20000.0f); + spectrogram->setDecibelRange (-100.0f, 0.0f); + spectrogram->setSampleRate (48000.0); + spectrogram->setNumHistoryFrames (128); + spectrogram->setOverlapFactor (0.5f); + spectrogram->setColorMap (SpectrogramColorMap::Type::warm); + + const auto testData = createSineBuffer (4096, 100.0f); + state->pushSamples (testData.data(), static_cast (testData.size())); + spectrogram->timerCallback(); + + auto context = yup_constructHeadlessGraphicsContext ({}); + auto renderer = context->makeRenderer (800, 400); + Graphics g (*context, *renderer); + + spectrogram->paint (g); + + EXPECT_EQ (4096, spectrogram->getFFTSize()); + EXPECT_EQ (WindowType::hamming, spectrogram->getWindowType()); + EXPECT_EQ (30, spectrogram->getUpdateRate()); + EXPECT_EQ (128, spectrogram->getNumHistoryFrames()); + EXPECT_FLOAT_EQ (0.5f, spectrogram->getOverlapFactor()); +} From 2aee87417209ddae6d49f66d5766236f5b9d1cfc Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 28 May 2026 12:45:38 +0200 Subject: [PATCH 3/3] More image tests --- tests/yup_graphics.cpp | 1 + tests/yup_graphics/yup_Image.cpp | 169 +++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/tests/yup_graphics.cpp b/tests/yup_graphics.cpp index b442256f4..46c65d4be 100644 --- a/tests/yup_graphics.cpp +++ b/tests/yup_graphics.cpp @@ -25,6 +25,7 @@ #include "yup_graphics/yup_Drawable.cpp" #include "yup_graphics/yup_Font.cpp" #include "yup_graphics/yup_Graphics.cpp" +#include "yup_graphics/yup_Image.cpp" #include "yup_graphics/yup_Line.cpp" #include "yup_graphics/yup_Path.cpp" #include "yup_graphics/yup_Point.cpp" diff --git a/tests/yup_graphics/yup_Image.cpp b/tests/yup_graphics/yup_Image.cpp index 10583bead..958abce36 100644 --- a/tests/yup_graphics/yup_Image.cpp +++ b/tests/yup_graphics/yup_Image.cpp @@ -23,6 +23,10 @@ #include +#include +#include +#include + using namespace yup; TEST (ImageTests, RgbaBitmapStoresRGBABytesAndReturnsARGBColor) @@ -98,6 +102,34 @@ TEST (BitmapDataTests, RgbaSetPixelWritesAtCorrectRowOffset) EXPECT_EQ (bitmap.getPixel (1, 1), 0x80123456u); } +TEST (BitmapDataTests, DefaultConstructorCreatesEmptyBitmap) +{ + BitmapData bitmap; + + EXPECT_EQ (bitmap.getWidth(), 0); + EXPECT_EQ (bitmap.getHeight(), 0); + EXPECT_EQ (bitmap.getPixelFormat(), PixelFormat::RGBA); + EXPECT_EQ (bitmap.getPixelStride(), 4); + EXPECT_TRUE (bitmap.getRawData().empty()); + + EXPECT_NO_THROW (bitmap.clear()); + EXPECT_NO_THROW (bitmap.fill (0xffffffff)); + EXPECT_THROW (bitmap.getPixel (0, 0), std::out_of_range); +} + +TEST (BitmapDataTests, ConstructorAdoptsProvidedPixelData) +{ + auto pixels = std::unique_ptr (new uint8[4] { 0x12, 0x34, 0x56, 0x80 }); + + BitmapData bitmap (1, 1, PixelFormat::RGBA, std::unique_ptr (pixels.release())); + + EXPECT_EQ (bitmap.getRawData().size(), 4u); + EXPECT_EQ (bitmap.getPixel (0, 0), 0x80123456u); + + bitmap.setPixel (0, 0, 0xffabcdef); + EXPECT_EQ (bitmap.getPixel (0, 0), 0xffabcdefu); +} + TEST (BitmapDataTests, FillWritesExpectedBytesForRGBA) { BitmapData bitmap (2, 2, PixelFormat::RGBA); @@ -133,6 +165,44 @@ TEST (BitmapDataTests, FillWritesExpectedBytesForRGB) EXPECT_EQ (raw[5], 0x56); } +TEST (BitmapDataTests, FillWritesExpectedBytesForGrayscale) +{ + BitmapData bitmap (3, 1, PixelFormat::Grayscale); + + bitmap.fillColor (Color (0xff00ff00)); + + const auto raw = bitmap.getRawData(); + + ASSERT_EQ (raw.size(), 3u); + EXPECT_EQ (raw[0], 182); + EXPECT_EQ (raw[1], 182); + EXPECT_EQ (raw[2], 182); + EXPECT_EQ (bitmap.getPixel (2, 0), 0xffb6b6b6u); +} + +TEST (BitmapDataTests, SetPixelColorAndGetPixelColorRoundTrip) +{ + BitmapData bitmap (1, 1, PixelFormat::RGBA); + + bitmap.setPixelColor (0, 0, Color (0x80123456)); + + EXPECT_EQ (bitmap.getPixelColor (0, 0), Color (0x80123456)); +} + +TEST (BitmapDataTests, MutableRawDataUpdatesPixelValues) +{ + BitmapData bitmap (1, 1, PixelFormat::RGB); + + auto raw = bitmap.getRawData(); + ASSERT_EQ (raw.size(), 3u); + + raw[0] = 0x12; + raw[1] = 0x34; + raw[2] = 0x56; + + EXPECT_EQ (bitmap.getPixel (0, 0), 0xff123456u); +} + TEST (BitmapDataTests, ClearZerosRawData) { BitmapData bitmap (2, 2, PixelFormat::RGBA); @@ -187,6 +257,7 @@ TEST (BitmapDataTests, ConstructorRejectsInvalidDimensions) EXPECT_THROW (BitmapData (1, 0, PixelFormat::RGBA), std::invalid_argument); EXPECT_THROW (BitmapData (-1, 1, PixelFormat::RGBA), std::invalid_argument); EXPECT_THROW (BitmapData (1, -1, PixelFormat::RGBA), std::invalid_argument); + EXPECT_THROW (BitmapData (1, 1, static_cast (255)), std::runtime_error); } TEST (BitmapDataTests, PixelAccessRejectsOutOfRangeCoordinates) @@ -198,6 +269,30 @@ TEST (BitmapDataTests, PixelAccessRejectsOutOfRangeCoordinates) EXPECT_THROW (bitmap.setPixel (2, 0, 0xffffffff), std::out_of_range); EXPECT_THROW (bitmap.setPixel (0, 2, 0xffffffff), std::out_of_range); EXPECT_THROW (bitmap.getPixel (2, 0), std::out_of_range); + EXPECT_THROW (bitmap.getPixelColor (0, 2), std::out_of_range); +} + +TEST (ImageTests, DefaultConstructorCreatesInvalidImage) +{ + const Image image; + + EXPECT_FALSE (image.isValid()); +} + +TEST (ImageTests, ConstructorExposesBitmapMetadata) +{ + Image image (3, 2, PixelFormat::RGB); + + EXPECT_TRUE (image.isValid()); + EXPECT_EQ (image.getWidth(), 3); + EXPECT_EQ (image.getHeight(), 2); + EXPECT_EQ (image.getPixelFormat(), PixelFormat::RGB); + EXPECT_EQ (image.getPixelStride(), 3); + EXPECT_EQ (image.getRawData().size(), 18u); + + const auto& constImage = image; + EXPECT_EQ (constImage.getBitmapData().getWidth(), 3); + EXPECT_EQ (image.getBitmapData().getHeight(), 2); } TEST (ImageTests, FillColorAndClearRoundTrip) @@ -213,6 +308,49 @@ TEST (ImageTests, FillColorAndClearRoundTrip) EXPECT_EQ (image.getPixel (1, 1), 0u); } +TEST (ImageTests, MutableRawDataUpdatesPixelValues) +{ + Image image (1, 1, PixelFormat::RGBA); + + auto raw = image.getRawData(); + ASSERT_EQ (raw.size(), 4u); + + raw[0] = 0x12; + raw[1] = 0x34; + raw[2] = 0x56; + raw[3] = 0x80; + + EXPECT_EQ (image.getPixelColor (0, 0), Color (0x80123456)); +} + +TEST (ImageTests, CopyConstructorPreservesBitmapData) +{ + Image original (1, 1, PixelFormat::RGBA); + original.setPixel (0, 0, 0x80123456); + + Image copy (original); + + EXPECT_TRUE (copy.isValid()); + EXPECT_EQ (copy.getWidth(), 1); + EXPECT_EQ (copy.getHeight(), 1); + EXPECT_EQ (copy.getPixelFormat(), PixelFormat::RGBA); + EXPECT_EQ (copy.getPixel (0, 0), 0x80123456u); +} + +TEST (ImageTests, CopyAssignmentPreservesBitmapData) +{ + Image original (1, 1, PixelFormat::RGB); + original.setPixel (0, 0, 0x80123456); + + Image copy (2, 2, PixelFormat::RGBA); + copy = original; + + EXPECT_EQ (copy.getWidth(), 1); + EXPECT_EQ (copy.getHeight(), 1); + EXPECT_EQ (copy.getPixelFormat(), PixelFormat::RGB); + EXPECT_EQ (copy.getPixel (0, 0), 0xff123456u); +} + TEST (ImageTests, MoveConstructorTransfersBitmapData) { Image source (1, 1, PixelFormat::RGBA); @@ -226,3 +364,34 @@ TEST (ImageTests, MoveConstructorTransfersBitmapData) EXPECT_EQ (moved.getHeight(), 1); EXPECT_EQ (moved.getPixel (0, 0), 0x80123456u); } + +TEST (ImageTests, MoveAssignmentTransfersBitmapData) +{ + Image source (1, 1, PixelFormat::RGBA); + source.setPixel (0, 0, 0x80123456); + + Image moved (2, 2, PixelFormat::RGB); + moved = std::move (source); + + EXPECT_FALSE (source.isValid()); + EXPECT_TRUE (moved.isValid()); + EXPECT_EQ (moved.getWidth(), 1); + EXPECT_EQ (moved.getHeight(), 1); + EXPECT_EQ (moved.getPixel (0, 0), 0x80123456u); +} + +TEST (ImageTests, ConstructorRejectsInvalidDimensions) +{ + EXPECT_THROW (Image (0, 1, PixelFormat::RGBA), std::invalid_argument); + EXPECT_THROW (Image (1, 0, PixelFormat::RGBA), std::invalid_argument); +} + +TEST (ImageTests, PixelAccessRejectsOutOfRangeCoordinates) +{ + Image image (2, 2, PixelFormat::RGBA); + + EXPECT_THROW (image.setPixel (-1, 0, 0xffffffff), std::out_of_range); + EXPECT_THROW (image.setPixelColor (0, -1, Color (0xffffffff)), std::out_of_range); + EXPECT_THROW (image.getPixel (2, 0), std::out_of_range); + EXPECT_THROW (image.getPixelColor (0, 2), std::out_of_range); +}