From 61318603da80120a1599cd5e9ef2c2a7503ed40c Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 16 Jul 2025 14:19:56 +0200 Subject: [PATCH 01/12] Work on the midi keyboard --- cmake/yup_modules.cmake | 10 +- examples/graphics/CMakeLists.txt | 1 + .../keyboard/yup_MidiKeyboardComponent.cpp | 493 ++++++++++++++++++ .../keyboard/yup_MidiKeyboardComponent.h | 218 ++++++++ modules/yup_audio_gui/yup_audio_gui.cpp | 35 ++ modules/yup_audio_gui/yup_audio_gui.h | 50 ++ modules/yup_audio_gui/yup_audio_gui.mm | 22 + .../themes/theme_v1/yup_ThemeVersion1.cpp | 136 +++++ 8 files changed, 957 insertions(+), 8 deletions(-) create mode 100644 modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp create mode 100644 modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h create mode 100644 modules/yup_audio_gui/yup_audio_gui.cpp create mode 100644 modules/yup_audio_gui/yup_audio_gui.h create mode 100644 modules/yup_audio_gui/yup_audio_gui.mm diff --git a/cmake/yup_modules.cmake b/cmake/yup_modules.cmake index 92f41ad74..f7e95c20d 100644 --- a/cmake/yup_modules.cmake +++ b/cmake/yup_modules.cmake @@ -624,30 +624,24 @@ macro (yup_add_default_modules modules_path) set (modules_group "Modules") yup_add_module (${modules_path}/modules/yup_core "${modules_definitions}" ${modules_group}) add_library (yup::yup_core ALIAS yup_core) - yup_add_module (${modules_path}/modules/yup_events "${modules_definitions}" ${modules_group}) add_library (yup::yup_events ALIAS yup_events) - yup_add_module (${modules_path}/modules/yup_data_model "${modules_definitions}" ${modules_group}) add_library (yup::yup_data_model ALIAS yup_data_model) - yup_add_module (${modules_path}/modules/yup_audio_basics "${modules_definitions}" ${modules_group}) add_library (yup::yup_audio_basics ALIAS yup_audio_basics) - yup_add_module (${modules_path}/modules/yup_audio_devices "${modules_definitions}" ${modules_group}) add_library (yup::yup_audio_devices ALIAS yup_audio_devices) - yup_add_module (${modules_path}/modules/yup_audio_processors "${modules_definitions}" ${modules_group}) add_library (yup::yup_audio_processors ALIAS yup_audio_processors) - yup_add_module (${modules_path}/modules/yup_audio_plugin_client "${modules_definitions}" ${modules_group}) add_library (yup::yup_audio_plugin_client ALIAS yup_audio_plugin_client) - yup_add_module (${modules_path}/modules/yup_graphics "${modules_definitions}" ${modules_group}) add_library (yup::yup_graphics ALIAS yup_graphics) - yup_add_module (${modules_path}/modules/yup_gui "${modules_definitions}" ${modules_group}) add_library (yup::yup_gui ALIAS yup_gui) + yup_add_module (${modules_path}/modules/yup_audio_gui "${modules_definitions}" ${modules_group}) + add_library (yup::yup_audio_gui ALIAS yup_audio_gui) if (YUP_ARG_ENABLE_PYTHON) if (NOT YUP_BUILD_WHEEL) diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 74df7eeb8..a48e5a326 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -69,6 +69,7 @@ yup_standalone_app ( yup::yup_events yup::yup_graphics yup::yup_gui + yup::yup_audio_gui yup::yup_audio_processors libpng libwebp diff --git a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp new file mode 100644 index 000000000..e08c2ae70 --- /dev/null +++ b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp @@ -0,0 +1,493 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - 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 +{ + +namespace +{ + +//============================================================================== + + + +} // namespace + +//============================================================================== +// Color identifiers +const Identifier MidiKeyboardComponent::Style::whiteKeyColorId ("midiKeyboardWhiteKey"); +const Identifier MidiKeyboardComponent::Style::whiteKeyPressedColorId ("midiKeyboardWhiteKeyPressed"); +const Identifier MidiKeyboardComponent::Style::whiteKeyShadowColorId ("midiKeyboardWhiteKeyShadow"); +const Identifier MidiKeyboardComponent::Style::blackKeyColorId ("midiKeyboardBlackKey"); +const Identifier MidiKeyboardComponent::Style::blackKeyPressedColorId ("midiKeyboardBlackKeyPressed"); +const Identifier MidiKeyboardComponent::Style::blackKeyShadowColorId ("midiKeyboardBlackKeyShadow"); +const Identifier MidiKeyboardComponent::Style::keyOutlineColorId ("midiKeyboardKeyOutline"); + +//============================================================================== +MidiKeyboardComponent::MidiKeyboardComponent (MidiKeyboardState& stateToUse, Orientation orientationToUse) + : state (stateToUse), + orientation (orientationToUse) +{ + state.addListener (this); + setWantsKeyboardFocus (true); + //setMouseClickGrabsKeyboardFocus (true); +} + +MidiKeyboardComponent::~MidiKeyboardComponent() +{ + state.removeListener (this); +} + +//============================================================================== +void MidiKeyboardComponent::setVelocity (float newVelocity) +{ + velocity = jlimit (0.0f, 1.0f, newVelocity); +} + +void MidiKeyboardComponent::setMidiChannel (int midiChannelNumber) +{ + jassert (midiChannelNumber > 0 && midiChannelNumber <= 16); + + if (midiChannel != midiChannelNumber) + { + resetAnyKeysInUse(); + midiChannel = midiChannelNumber; + } +} + +void MidiKeyboardComponent::setOctaveForMiddleC (int octaveNumber) +{ + octaveNumForMiddleC = octaveNumber; + repaint(); +} + +void MidiKeyboardComponent::setLowestVisibleKey (int noteNumber) +{ + setAvailableRange (noteNumber, rangeEnd); +} + +void MidiKeyboardComponent::setAvailableRange (int lowestNote, int highestNote) +{ + jassert (lowestNote >= 0 && lowestNote <= 127); + jassert (highestNote >= 0 && highestNote <= 127); + jassert (lowestNote <= highestNote); + + if (rangeStart != lowestNote || rangeEnd != highestNote) + { + rangeStart = jlimit (0, 127, lowestNote); + rangeEnd = jlimit (0, 127, highestNote); + repaint(); + } +} + +//============================================================================== +Rectangle MidiKeyboardComponent::getRectangleForKey (int midiNoteNumber) const +{ + jassert (midiNoteNumber >= 0 && midiNoteNumber < 128); + + if (midiNoteNumber < rangeStart || midiNoteNumber > rangeEnd) + return {}; + + auto keyWidth = getKeyStartRange().getLength() / getNumWhiteKeysInRange (rangeStart, rangeEnd + 1); + Rectangle pos; + bool isBlack; + + getKeyPosition (midiNoteNumber, keyWidth, pos, isBlack); + + return pos; +} + +int MidiKeyboardComponent::getNoteAtPosition (Point position) const +{ + float mousePositionVelocity; + return remappedXYToNote (position, mousePositionVelocity); +} + +//============================================================================== +void MidiKeyboardComponent::paint (Graphics& g) +{ + if (auto style = ApplicationTheme::findComponentStyle (*this)) + style->paint (g, *ApplicationTheme::getGlobalTheme(), *this); +} + +//============================================================================== +void MidiKeyboardComponent::mouseDown (const MouseEvent& e) +{ + if (! isEnabled()) + return; + + updateNoteUnderMouse (e, true); + shouldCheckState = true; +} + +void MidiKeyboardComponent::mouseDrag (const MouseEvent& e) +{ + if (! isEnabled()) + return; + + updateNoteUnderMouse (e, true); +} + +void MidiKeyboardComponent::mouseUp (const MouseEvent& e) +{ + if (! isEnabled()) + return; + + updateNoteUnderMouse (e, false); + + for (int i = mouseDownNotes.size(); --i >= 0;) + { + const int noteDown = mouseDownNotes.getUnchecked (i); + + if (mouseDraggedToKey (noteDown, e)) + { + mouseDownNotes.remove (i); + } + else + { + state.noteOff (midiChannel, noteDown, velocity); + mouseDownNotes.remove (i); + } + } + + updateShadowNoteUnderMouse (e); + shouldCheckState = true; +} + +void MidiKeyboardComponent::mouseEnter (const MouseEvent& e) +{ + updateShadowNoteUnderMouse (e); +} + +void MidiKeyboardComponent::mouseExit (const MouseEvent& e) +{ + updateShadowNoteUnderMouse (e); +} + +void MidiKeyboardComponent::mouseWheel (const MouseEvent&, const MouseWheelData& wheel) +{ + const auto amount = (orientation == horizontalKeyboard && wheel.getDeltaX() != 0) + ? wheel.getDeltaX() + : (orientation != horizontalKeyboard && wheel.getDeltaY() != 0) + ? wheel.getDeltaY() : wheel.getDeltaX(); + + setLowestVisibleKey (rangeStart + roundToInt (amount * 5.0f)); +} + +//============================================================================== +void MidiKeyboardComponent::handleNoteOn (MidiKeyboardState*, int midiChannelNumber, int midiNoteNumber, float) +{ + if (midiInChannelMask & (1 << (midiChannelNumber - 1))) + repaintNote (midiNoteNumber); +} + +void MidiKeyboardComponent::handleNoteOff (MidiKeyboardState*, int midiChannelNumber, int midiNoteNumber, float) +{ + if (midiInChannelMask & (1 << (midiChannelNumber - 1))) + repaintNote (midiNoteNumber); +} + +//============================================================================== +void MidiKeyboardComponent::resized() +{ + shouldCheckState = true; +} + +void MidiKeyboardComponent::keyDown (const KeyPress& key, const Point& position) +{ + int midiNote = -1; + + if (key == KeyPress ('z')) midiNote = 0; + else if (key == KeyPress ('s')) midiNote = 1; + else if (key == KeyPress ('x')) midiNote = 2; + else if (key == KeyPress ('d')) midiNote = 3; + else if (key == KeyPress ('c')) midiNote = 4; + else if (key == KeyPress ('v')) midiNote = 5; + else if (key == KeyPress ('g')) midiNote = 6; + else if (key == KeyPress ('b')) midiNote = 7; + else if (key == KeyPress ('h')) midiNote = 8; + else if (key == KeyPress ('n')) midiNote = 9; + else if (key == KeyPress ('j')) midiNote = 10; + else if (key == KeyPress ('m')) midiNote = 11; + else if (key == KeyPress (',')) midiNote = 12; + else if (key == KeyPress ('l')) midiNote = 13; + else if (key == KeyPress ('.')) midiNote = 14; + else if (key == KeyPress (';')) midiNote = 15; + else if (key == KeyPress ('/')) midiNote = 16; + + if (midiNote >= 0) + { + midiNote += 12 * octaveNumForMiddleC; + + if (midiNote >= 0 && midiNote < 128) + state.noteOn (midiChannel, midiNote, velocity); + } +} + +void MidiKeyboardComponent::focusLost() +{ + resetAnyKeysInUse(); +} + +//============================================================================== +bool MidiKeyboardComponent::isNoteOn (int midiNoteNumber) const +{ + return state.isNoteOnForChannels (midiInChannelMask, midiNoteNumber); +} + +//============================================================================== +bool MidiKeyboardComponent::isBlackKey (int midiNoteNumber) const +{ + return MidiMessage::isMidiNoteBlack (midiNoteNumber); +} + +int MidiKeyboardComponent::getNumWhiteKeysInRange (int rangeStart, int rangeEnd) const +{ + int numWhiteKeys = 0; + + for (int i = rangeStart; i < rangeEnd; ++i) + if (! isBlackKey (i)) + ++numWhiteKeys; + + return numWhiteKeys; +} + +String MidiKeyboardComponent::getWhiteNoteText (int midiNoteNumber) +{ + if (isBlackKey (midiNoteNumber)) + return {}; + + static const char* const noteNames[] = { "C", "", "D", "", "E", "F", "", "G", "", "A", "", "B" }; + + return String (noteNames [midiNoteNumber % 12]); +} + +void MidiKeyboardComponent::getKeyPosition (int midiNoteNumber, float keyWidth, Rectangle& keyPos, bool& isBlack) const +{ + jassert (midiNoteNumber >= 0 && midiNoteNumber < 128); + + static const float blackKeyOffsets[] = { 0.0f, 0.6f, 0.0f, 0.7f, 0.0f, 0.0f, 0.6f, 0.0f, 0.65f, 0.0f, 0.7f, 0.0f }; + + auto octave = midiNoteNumber / 12; + auto note = midiNoteNumber % 12; + + auto numWhiteKeysBefore = 0; + auto notePos = 0; + + for (int i = 0; i < note; ++i) + { + if (! isBlackKey (i)) + ++numWhiteKeysBefore; + } + + for (int i = rangeStart; i < midiNoteNumber; ++i) + { + if (! isBlackKey (i)) + ++notePos; + } + + isBlack = isBlackKey (midiNoteNumber); + + auto x = notePos * keyWidth; + auto w = keyWidth; + + if (isBlack) + { + auto blackKeyWidth = keyWidth * 0.7f; + x = x - (blackKeyWidth * 0.5f) + (keyWidth * blackKeyOffsets[note]); + w = blackKeyWidth; + } + + switch (orientation) + { + case horizontalKeyboard: + keyPos = Rectangle (x, 0.0f, w, (float) getHeight()); + break; + + case verticalKeyboardFacingLeft: + keyPos = Rectangle ((float) getWidth() - ((isBlack ? 0.7f : 1.0f) * (float) getWidth()), + x, (isBlack ? 0.7f : 1.0f) * (float) getWidth(), w); + break; + + case verticalKeyboardFacingRight: + keyPos = Rectangle (0.0f, (float) getHeight() - x - w, + (isBlack ? 0.7f : 1.0f) * (float) getWidth(), w); + break; + + default: + break; + } + + if (isBlack) + { + switch (orientation) + { + case horizontalKeyboard: keyPos = keyPos.withHeight (keyPos.getHeight() * 0.6f); break; + case verticalKeyboardFacingLeft: keyPos = keyPos.withWidth (keyPos.getWidth() * 0.6f); break; + case verticalKeyboardFacingRight: keyPos = keyPos.withX (keyPos.getX() + keyPos.getWidth() * 0.4f) + .withWidth (keyPos.getWidth() * 0.6f); break; + default: break; + } + } +} + +Range MidiKeyboardComponent::getKeyStartRange() const +{ + return (orientation == horizontalKeyboard) ? Range (0.0f, (float) getWidth()) + : Range (0.0f, (float) getHeight()); +} + +int MidiKeyboardComponent::xyToNote (Point pos, float& mousePositionVelocity) +{ + return remappedXYToNote (pos, mousePositionVelocity); +} + +int MidiKeyboardComponent::remappedXYToNote (Point pos, float& mousePositionVelocity) const +{ + auto keyWidth = getKeyStartRange().getLength() / getNumWhiteKeysInRange (rangeStart, rangeEnd + 1); + + auto coord = (orientation == horizontalKeyboard) ? pos.getX() : pos.getY(); + auto otherCoord = (orientation == horizontalKeyboard) ? pos.getY() : pos.getX(); + + auto blackKeyDepth = 0.7f; + + switch (orientation) + { + case horizontalKeyboard: blackKeyDepth = getHeight() * 0.6f; break; + case verticalKeyboardFacingLeft: blackKeyDepth = getWidth() * 0.6f; break; + case verticalKeyboardFacingRight: blackKeyDepth = getWidth() * 0.6f; break; + default: break; + } + + // First try black keys + for (int note = rangeStart; note <= rangeEnd; ++note) + { + if (isBlackKey (note)) + { + Rectangle area; + bool isBlack; + getKeyPosition (note, keyWidth, area, isBlack); + + if (area.contains (pos)) + { + mousePositionVelocity = jlimit (0.0f, 1.0f, otherCoord / area.getHeight()); + return note; + } + } + } + + // Then try white keys + for (int note = rangeStart; note <= rangeEnd; ++note) + { + if (! isBlackKey (note)) + { + Rectangle area; + bool isBlack; + getKeyPosition (note, keyWidth, area, isBlack); + + if (area.contains (pos)) + { + mousePositionVelocity = jlimit (0.0f, 1.0f, otherCoord / area.getHeight()); + return note; + } + } + } + + mousePositionVelocity = velocity; + return -1; +} + +void MidiKeyboardComponent::repaintNote (int midiNoteNumber) +{ + if (midiNoteNumber >= rangeStart && midiNoteNumber <= rangeEnd) + repaint (getRectangleForKey (midiNoteNumber).roundToInt().enlarged (1)); // getSmallestIntegerContainer +} + +void MidiKeyboardComponent::updateNoteUnderMouse (Point pos, bool isDown, int fingerNum) +{ + float mousePositionVelocity; + auto newNote = xyToNote (pos, mousePositionVelocity); + auto oldNote = mouseOverNote; + + if (oldNote != newNote) + { + repaintNote (oldNote); + repaintNote (newNote); + mouseOverNote = newNote; + } + + if (isDown) + { + if (newNote != oldNote) + { + if (oldNote >= 0) + { + mouseDownNotes.removeFirstMatchingValue (oldNote); + state.noteOff (midiChannel, oldNote, mousePositionVelocity); + } + + if (newNote >= 0 && ! mouseDownNotes.contains (newNote)) + { + state.noteOn (midiChannel, newNote, mousePositionVelocity); + mouseDownNotes.add (newNote); + } + } + } +} + +void MidiKeyboardComponent::updateNoteUnderMouse (const MouseEvent& e, bool isDown) +{ + updateNoteUnderMouse (e.getPosition(), isDown, 0); +} + +bool MidiKeyboardComponent::mouseDraggedToKey (int midiNoteNumber, const MouseEvent& e) +{ + jassert (midiNoteNumber >= 0 && midiNoteNumber < 128); + + return getRectangleForKey (midiNoteNumber).contains (e.getPosition()); +} + +void MidiKeyboardComponent::resetAnyKeysInUse() +{ + if (! mouseDownNotes.isEmpty()) + { + for (auto noteDown : mouseDownNotes) + state.noteOff (midiChannel, noteDown, velocity); + + mouseDownNotes.clear(); + } + + mouseOverNote = -1; +} + +void MidiKeyboardComponent::updateShadowNoteUnderMouse (const MouseEvent& e) +{ + auto note = getNoteAtPosition (e.getPosition()); + + if (note != mouseOverNote) + { + repaintNote (mouseOverNote); + mouseOverNote = note; + repaintNote (mouseOverNote); + } +} + +} // namespace yup diff --git a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h new file mode 100644 index 000000000..af95ccf25 --- /dev/null +++ b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h @@ -0,0 +1,218 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - 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 component that displays a virtual MIDI keyboard. + + This component renders a piano-style keyboard with white and black keys that + responds to mouse interactions and updates a MidiKeyboardState object. It also + monitors the state to visually show which keys are currently pressed. + + The actual drawing is delegated to the ApplicationTheme system. + + @tags{AudioGUI} +*/ +class YUP_API MidiKeyboardComponent : public Component, + public MidiKeyboardState::Listener +{ +public: + //============================================================================== + /** The different orientations that the keyboard can have. */ + enum Orientation + { + horizontalKeyboard, + verticalKeyboardFacingLeft, + verticalKeyboardFacingRight + }; + + //============================================================================== + /** Creates a MidiKeyboardComponent. + + @param state the MidiKeyboardState object that this keyboard will use to show + which keys are down, and which the user can use to trigger key events + @param orientation whether the keyboard is horizontal or vertical + */ + MidiKeyboardComponent (MidiKeyboardState& state, Orientation orientation); + + /** Destructor. */ + ~MidiKeyboardComponent() override; + + //============================================================================== + /** Changes the velocity used in midi note-on messages that are triggered by clicking + on the component. + + @param velocity the new velocity, in the range 0 to 1.0 + */ + void setVelocity (float velocity); + + /** Returns the current velocity setting. */ + float getVelocity() const noexcept { return velocity; } + + //============================================================================== + /** Changes the midi channel number that will be used for events triggered by clicking + on the component. + + @param midiChannelNumber the midi channel (1 to 16). Events with midi + channel numbers outside this range are ignored + */ + void setMidiChannel (int midiChannelNumber); + + /** Returns the midi channel that the keyboard is using for midi messages. */ + int getMidiChannel() const noexcept { return midiChannel; } + + //============================================================================== + /** Changes the number of octaves displayed by the keyboard. + + @param numOctaves the number of octaves to display + */ + void setOctaveForMiddleC (int octaveNumber); + + /** Returns the number of octaves currently being displayed. */ + int getOctaveForMiddleC() const noexcept { return octaveNumForMiddleC; } + + //============================================================================== + /** Changes the lowest visible key on the keyboard. + + @param noteNumber the midi note number (0-127) of the lowest key to be shown + */ + void setLowestVisibleKey (int noteNumber); + + /** Returns the lowest visible key. */ + int getLowestVisibleKey() const noexcept { return rangeStart; } + + /** Sets the range of keys that the keyboard will display. + + @param lowestNote the lowest key (0-127) + @param highestNote the highest key (0-127) + */ + void setAvailableRange (int lowestNote, int highestNote); + + /** Returns the highest key that is shown on the keyboard. */ + int getHighestVisibleKey() const noexcept { return rangeEnd; } + + Range getKeyStartRange() const; + + //============================================================================== + /** Returns the position within the component of a key. + + @param midiNoteNumber the note to find the position of + @returns the key's rectangle, or an empty rectangle if the key isn't visible + */ + Rectangle getRectangleForKey (int midiNoteNumber) const; + + /** Returns the note number of the key at a given position within the component. + + @param position the position to search + @returns the midi note number of the key, or -1 if there's no key there + */ + int getNoteAtPosition (Point position) const; + + //============================================================================== + + bool isNoteOn (int midiNoteNumber) const; + + //============================================================================== + + virtual bool isBlackKey (int midiNoteNumber) const; + + int getNumWhiteKeysInRange (int rangeStart, int rangeEnd) const; + + //============================================================================== + /** Color identifiers used by the midi keyboard component. */ + struct Style + { + static const Identifier whiteKeyColorId; + static const Identifier whiteKeyPressedColorId; + static const Identifier whiteKeyShadowColorId; + static const Identifier blackKeyColorId; + static const Identifier blackKeyPressedColorId; + static const Identifier blackKeyShadowColorId; + static const Identifier keyOutlineColorId; + }; + + //============================================================================== + /** @internal */ + void paint (Graphics& g) override; + /** @internal */ + void mouseDown (const MouseEvent& e) override; + /** @internal */ + void mouseDrag (const MouseEvent& e) override; + /** @internal */ + void mouseUp (const MouseEvent& e) override; + /** @internal */ + void mouseEnter (const MouseEvent& e) override; + /** @internal */ + void mouseExit (const MouseEvent& e) override; + /** @internal */ + void mouseWheel (const MouseEvent& e, const MouseWheelData& wheel) override; + /** @internal */ + void handleNoteOn (MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override; + /** @internal */ + void handleNoteOff (MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override; + /** @internal */ + void resized() override; + /** @internal */ + void keyDown (const KeyPress& key, const Point& position) override; + /** @internal */ + void focusLost () override; + + //============================================================================== + /** @internal */ + void getKeyPosition (int midiNoteNumber, float keyWidth, Rectangle& keyPos, bool& isBlack) const; + /** @internal */ + bool isMouseOverNote (int midiNoteNumber) const { return midiNoteNumber == mouseOverNote; } + +private: + //============================================================================== + MidiKeyboardState& state; + + int midiChannel = 1; + int midiInChannelMask = 0xffff; + float velocity = 1.0f; + + int rangeStart = 12; + int rangeEnd = 96; + int octaveNumForMiddleC = 3; + + Orientation orientation; + + Array mouseDownNotes; + int mouseOverNote = -1; + bool shouldCheckState = false; + + String getWhiteNoteText (int midiNoteNumber); + int xyToNote (Point pos, float& mousePositionVelocity); + int remappedXYToNote (Point pos, float& mousePositionVelocity) const; + void repaintNote (int midiNoteNumber); + void updateNoteUnderMouse (Point pos, bool isDown, int fingerNum); + void updateNoteUnderMouse (const MouseEvent& e, bool isDown); + bool mouseDraggedToKey (int midiNoteNumber, const MouseEvent& e); + void resetAnyKeysInUse(); + void updateShadowNoteUnderMouse (const MouseEvent& e); + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MidiKeyboardComponent) +}; + +} // namespace yup diff --git a/modules/yup_audio_gui/yup_audio_gui.cpp b/modules/yup_audio_gui/yup_audio_gui.cpp new file mode 100644 index 000000000..9d908d2d9 --- /dev/null +++ b/modules/yup_audio_gui/yup_audio_gui.cpp @@ -0,0 +1,35 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - 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. + + ============================================================================== +*/ + +#ifdef YUP_AUDIO_GUI_H_INCLUDED + /* When you add this cpp file to your project, you mustn't include it in a file where you've + already included any other headers - just put it inside a file on its own, possibly with your config + flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix + header files that the compiler may be using. + */ + #error "Incorrect use of YUP cpp file" +#endif + +#include "yup_audio_gui.h" + +//============================================================================== + +#include "keyboard/yup_MidiKeyboardComponent.cpp" diff --git a/modules/yup_audio_gui/yup_audio_gui.h b/modules/yup_audio_gui/yup_audio_gui.h new file mode 100644 index 000000000..cd3f0f2d4 --- /dev/null +++ b/modules/yup_audio_gui/yup_audio_gui.h @@ -0,0 +1,50 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - 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. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: yup_audio_gui + vendor: yup + version: 1.0.0 + name: YUP Audio GUI Components + description: Audio-related GUI components for the YUP library + website: https://github.com/kunitoki/yup + license: ISC + + dependencies: yup_audio_basics yup_gui + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once +#define YUP_AUDIO_GUI_H_INCLUDED + +#include +#include + +//============================================================================== + +#include "keyboard/yup_MidiKeyboardComponent.h" \ No newline at end of file diff --git a/modules/yup_audio_gui/yup_audio_gui.mm b/modules/yup_audio_gui/yup_audio_gui.mm new file mode 100644 index 000000000..b4c3aa977 --- /dev/null +++ b/modules/yup_audio_gui/yup_audio_gui.mm @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2025 - 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 "yup_audio_gui.cpp" diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index bb2a1fa1c..c9e91f7dd 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -19,6 +19,10 @@ ============================================================================== */ +#if YUP_MODULE_AVAILABLE_yup_audio_gui +#include +#endif + namespace yup { @@ -567,6 +571,127 @@ void paintPopupMenu (Graphics& g, const ApplicationTheme& theme, const PopupMenu } } +//============================================================================== +#if YUP_MODULE_AVAILABLE_yup_audio_gui +void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKeyboardComponent& keyboard) +{ + auto bounds = keyboard.getLocalBounds(); + + if (bounds.isEmpty()) + return; + + auto getNumWhiteKeysInRange = [](int rangeStart, int rangeEnd) + { + int numWhiteKeys = 0; + + for (int i = rangeStart; i < rangeEnd; ++i) + if (! MidiMessage::isMidiNoteBlack (i)) + ++numWhiteKeys; + + return numWhiteKeys; + }; + + auto keyWidth = keyboard.getKeyStartRange().getLength() / getNumWhiteKeysInRange (keyboard.getLowestVisibleKey(), + keyboard.getHighestVisibleKey() + 1); + + // Paint white keys first + for (int note = keyboard.getLowestVisibleKey(); note <= keyboard.getHighestVisibleKey(); ++note) + { + if (! keyboard.isBlackKey (note)) + { + bool isBlack; + Rectangle keyArea; + keyboard.getKeyPosition (note, keyWidth, keyArea, isBlack); + + auto isPressed = keyboard.isNoteOn (note); + auto isOver = keyboard.isMouseOverNote (note); + + // Use theme colors + auto fillColor = isPressed ? ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyPressedColorId) + : ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyColorId); + + auto shadowColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyShadowColorId); + auto outlineColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::keyOutlineColorId); + + // Draw 3D effect + auto shadowArea = keyArea.reduced (1.0f); + shadowArea.translate (2.0f, 2.0f); + + g.setFillColor (shadowColor); + g.fillRoundedRect (shadowArea, 2.0f); + + // Main key + g.setFillColor (fillColor); + g.fillRoundedRect (keyArea.reduced (0.5f), 1.5f); + + // Highlight for 3D effect + if (! isPressed) + { + g.setFillColor (fillColor.brighter (0.3f)); + g.fillRoundedRect (keyArea.reduced (1.0f).removeFromTop (keyArea.getHeight() * 0.3f), 1.5f); + } + + // Outline + g.setStrokeColor (outlineColor); + g.setStrokeWidth (1.0f); + g.strokeRoundedRect (keyArea.reduced (0.5f), 1.5f); + + // Note text + /* + if (keyboard.getWidth() > 20 && keyArea.getWidth() > 20.0f) + { + auto noteText = MidiKeyboardComponent::getWhiteNoteText (note); + if (noteText.isNotEmpty()) + { + g.setFillColor (outlineColor); + g.drawText (noteText, keyArea.reduced (2.0f).removeFromBottom (15.0f), + Justification::centred, false); + } + } + */ + } + } + + // Paint black keys on top + for (int note = keyboard.getLowestVisibleKey(); note <= keyboard.getHighestVisibleKey(); ++note) + { + if (keyboard.isBlackKey (note)) + { + bool isBlack; + Rectangle keyArea; + keyboard.getKeyPosition (note, keyWidth, keyArea, isBlack); + + auto isPressed = keyboard.isNoteOn (note); + auto isOver = keyboard.isMouseOverNote (note); + + // Use theme colors + auto fillColor = isPressed ? ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyPressedColorId) + : ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyColorId); + + auto shadowColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyShadowColorId); + + // Draw 3D effect + auto shadowArea = keyArea.reduced (0.5f); + shadowArea.translate (1.5f, 1.5f); + + g.setFillColor (shadowColor); + g.fillRoundedRect (shadowArea, 2.0f); + + // Main key + g.setFillColor (fillColor); + g.fillRoundedRect (keyArea.reduced (0.25f), 1.5f); + + // Highlight for 3D effect + if (! isPressed) + { + g.setFillColor (fillColor.brighter (0.2f)); + g.fillRoundedRect (keyArea.reduced (1.0f).removeFromTop (keyArea.getHeight() * 0.2f), 1.5f); + } + } + } +} +#endif + //============================================================================== ApplicationTheme::Ptr createThemeVersion1() @@ -594,6 +719,17 @@ ApplicationTheme::Ptr createThemeVersion1() theme->setComponentStyle (ComponentStyle::createStyle (paintPopupMenu)); +#if YUP_MODULE_AVAILABLE_yup_audio_gui + theme->setComponentStyle (ComponentStyle::createStyle (paintMidiKeyboard)); + theme->setColor (MidiKeyboardComponent::Style::whiteKeyColorId, Color (0xfff0f0f0)); + theme->setColor (MidiKeyboardComponent::Style::whiteKeyPressedColorId, Color (0xff4ebfff)); + theme->setColor (MidiKeyboardComponent::Style::whiteKeyShadowColorId, Color (0x40000000)); + theme->setColor (MidiKeyboardComponent::Style::blackKeyColorId, Color (0xff2a2a2a)); + theme->setColor (MidiKeyboardComponent::Style::blackKeyPressedColorId, Color (0xff7a7aff)); + theme->setColor (MidiKeyboardComponent::Style::blackKeyShadowColorId, Color (0x80000000)); + theme->setColor (MidiKeyboardComponent::Style::keyOutlineColorId, Color (0xff888888)); +#endif + return theme; } From 43d2f8742cfb58909e4db742b90d58b475adee75 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 16 Jul 2025 15:05:57 +0200 Subject: [PATCH 02/12] More keys --- examples/graphics/source/examples/Audio.h | 180 +++++++++++++++--- examples/graphics/source/main.cpp | 1 + .../keyboard/yup_MidiKeyboardComponent.cpp | 4 +- .../keyboard/yup_MidiKeyboardComponent.h | 5 +- modules/yup_graphics/graphics/yup_Color.h | 19 ++ .../yup_graphics/graphics/yup_ColorGradient.h | 21 +- .../themes/theme_v1/yup_ThemeVersion1.cpp | 152 ++++++++++----- 7 files changed, 301 insertions(+), 81 deletions(-) diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index 34c60fbe9..2327ba6d9 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -156,33 +156,52 @@ class Oscilloscope : public yup::Component class AudioExample : public yup::Component , public yup::AudioIODeviceCallback + , public yup::MidiKeyboardState::Listener { public: AudioExample() : Component ("AudioExample") + , keyboardComponent (keyboardState, yup::MidiKeyboardComponent::horizontalKeyboard) { // Initialize the audio device deviceManager.initialiseWithDefaultDevices (0, 2); - // Initialize sine wave generators + // Initialize sine wave generators for MIDI notes (128 possible notes) double sampleRate = deviceManager.getAudioDeviceSetup().sampleRate; - sineWaveGenerators.resize (totalRows * totalColumns); - for (size_t i = 0; i < sineWaveGenerators.size(); ++i) + sineWaveGenerators.resize (128); + for (int i = 0; i < 128; ++i) { sineWaveGenerators[i] = std::make_unique(); sineWaveGenerators[i]->setSampleRate (sampleRate); - sineWaveGenerators[i]->setFrequency (440.0 * std::pow (1.1, i), true); + // Convert MIDI note to frequency: f = 440 * 2^((n-69)/12) + double frequency = 440.0 * std::pow (2.0, (i - 69) / 12.0); + sineWaveGenerators[i]->setFrequency (frequency, true); + sineWaveGenerators[i]->setAmplitude (0.0f); // Start silent } - // Add sliders + // Set up MIDI keyboard + keyboardState.addListener (this); + keyboardComponent.setAvailableRange (36, 84); // C2 to C6 + keyboardComponent.setLowestVisibleKey (48); // Start from C3 + keyboardComponent.setMidiChannel (1); + keyboardComponent.setVelocity (0.7f); + addAndMakeVisible (keyboardComponent); + + // Add sliders for manual control (reduced number for layout) for (int i = 0; i < totalRows * totalColumns; ++i) { auto slider = sliders.add (std::make_unique (yup::String (i))); slider->onValueChanged = [this, i, sampleRate] (float value) { - sineWaveGenerators[i]->setFrequency (440.0 * std::pow (1.1, i + value)); - sineWaveGenerators[i]->setAmplitude (value * 0.5); + // Map sliders to a specific range of notes for demonstration + int noteNumber = 60 + i; // Start from middle C + if (noteNumber < 128) + { + double baseFreq = 440.0 * std::pow (2.0, (noteNumber - 69) / 12.0); + sineWaveGenerators[noteNumber]->setFrequency (baseFreq * (1.0 + value * 0.5)); + sineWaveGenerators[noteNumber]->setAmplitude (value * 0.3f); + } }; addAndMakeVisible (slider); @@ -197,48 +216,110 @@ class AudioExample }; addAndMakeVisible (*button); + // Add clear all notes button + clearButton = std::make_unique ("Clear All Notes"); + clearButton->onClick = [this] + { + keyboardState.allNotesOff (0); // Turn off all notes on all channels + }; + addAndMakeVisible (*clearButton); + // Add the oscilloscope addAndMakeVisible (oscilloscope); + + // Add volume control + volumeSlider = std::make_unique ("Volume"); + volumeSlider->onValueChanged = [this] (float value) + { + masterVolume = value; + }; + volumeSlider->setValue (0.5f); // Set initial volume to 50% + addAndMakeVisible (*volumeSlider); } ~AudioExample() override { + keyboardState.removeListener (this); deviceManager.removeAudioCallback (this); deviceManager.closeAudioDevice(); } void resized() override { - auto bounds = getLocalBounds().reduced (proportionOfWidth (0.1f), proportionOfHeight (0.2f)); - auto width = bounds.getWidth() / totalColumns; - auto height = bounds.getHeight() / totalRows; + auto bounds = getLocalBounds(); + + // Reserve space for MIDI keyboard at the bottom + auto keyboardHeight = proportionOfHeight (0.20f); + auto keyboardBounds = bounds.removeFromBottom (keyboardHeight); + keyboardComponent.setBounds (keyboardBounds.reduced (proportionOfWidth (0.02f), proportionOfHeight (0.01f))); + + // Reserve space for oscilloscope above the keyboard + auto oscilloscopeHeight = proportionOfHeight (0.2f); + auto oscilloscopeBounds = bounds.removeFromBottom (oscilloscopeHeight); + oscilloscope.setBounds (oscilloscopeBounds.reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + + // Reserve space for buttons at the top + bounds.removeFromTop (proportionOfHeight (0.1f)); + auto buttonHeight = proportionOfHeight (0.10f); + auto buttonArea = bounds.removeFromTop (buttonHeight); + + auto buttonWidth = buttonArea.getWidth() / 3; + if (button != nullptr) + button->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + + if (clearButton != nullptr) + clearButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + + if (volumeSlider != nullptr) + volumeSlider->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + + // Use remaining space for sliders + auto sliderBounds = bounds.reduced (proportionOfWidth (0.1f), proportionOfHeight (0.05f)); + auto width = sliderBounds.getWidth() / totalColumns; + auto height = sliderBounds.getHeight() / totalRows; for (int i = 0; i < totalRows && sliders.size(); ++i) { - auto row = bounds.removeFromTop (height); + auto row = sliderBounds.removeFromTop (height); for (int j = 0; j < totalColumns; ++j) { auto col = row.removeFromLeft (width); - sliders.getUnchecked (i * totalRows + j)->setBounds (col.largestFittingSquare()); + sliders.getUnchecked (i * totalColumns + j)->setBounds (col.largestFittingSquare()); } } - - if (button != nullptr) - button->setBounds (getLocalBounds() - .removeFromTop (proportionOfHeight (0.2f)) - .reduced (proportionOfWidth (0.2f), 0.0f)); - - auto bottomBounds = getLocalBounds() - .removeFromBottom (proportionOfHeight (0.2f)) - .reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f)); - - oscilloscope.setBounds (bottomBounds); } void paint (yup::Graphics& g) override { g.setFillColor (findColor (yup::DocumentWindow::Style::backgroundColorId).value_or (yup::Colors::dimgray)); g.fillAll(); + + // Draw some labels + auto bounds = getLocalBounds(); + auto titleArea = bounds.removeFromTop (proportionOfHeight (0.05f)); + auto subtitleArea = bounds.removeFromTop (proportionOfHeight (0.03f)); + + yup::StyledText titleText; + { + auto modifier = titleText.startUpdate(); + modifier.setMaxSize (titleArea.getSize()); + modifier.setHorizontalAlign (yup::StyledText::center); + modifier.appendText ("YUP Audio Synthesis Example with MIDI Keyboard", + yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(), 16.0f); + } + + yup::StyledText subtitleText; + { + auto modifier = subtitleText.startUpdate(); + modifier.setMaxSize (subtitleArea.getSize()); + modifier.setHorizontalAlign (yup::StyledText::center); + modifier.appendText ("Use the MIDI keyboard below or adjust sliders to generate tones", + yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(), 12.0f); + } + + g.setFillColor (yup::Colors::white); + g.fillFittedText (titleText, titleArea); + g.fillFittedText (subtitleText, subtitleArea); } void mouseDown (const yup::MouseEvent& event) override @@ -257,6 +338,23 @@ class AudioExample oscilloscope.repaint(); } + // MIDI keyboard event handlers + void handleNoteOn (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override + { + if (midiNoteNumber >= 0 && midiNoteNumber < 128) + { + sineWaveGenerators[midiNoteNumber]->setAmplitude (velocity * 0.5f); + } + } + + void handleNoteOff (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override + { + if (midiNoteNumber >= 0 && midiNoteNumber < 128) + { + sineWaveGenerators[midiNoteNumber]->setAmplitude (0.0f); + } + } + void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, int numInputChannels, float* const* outputChannelData, @@ -267,20 +365,34 @@ class AudioExample for (int sample = 0; sample < numSamples; ++sample) { float mixedSample = 0.0f; - float totalScale = 0.0f; + int activeNotes = 0; - for (int i = 0; i < sineWaveGenerators.size(); ++i) + // Mix all active MIDI notes + for (int i = 0; i < 128; ++i) { - mixedSample += sineWaveGenerators[i]->getNextSample(); - totalScale += sineWaveGenerators[i]->getAmplitude(); + float amplitude = sineWaveGenerators[i]->getAmplitude(); + if (amplitude > 0.001f) // Only process notes that are actually sounding + { + mixedSample += sineWaveGenerators[i]->getNextSample(); + activeNotes++; + } } - if (totalScale > 1.0f) - mixedSample /= static_cast (totalScale); + // Apply master volume and normalize if multiple notes are playing + if (activeNotes > 0) + { + mixedSample *= masterVolume; + if (activeNotes > 1) + mixedSample /= std::sqrt (static_cast (activeNotes)); // Gentle normalization + } + + // Apply soft limiting to prevent clipping + mixedSample = std::tanh (mixedSample); for (int channel = 0; channel < numOutputChannels; ++channel) outputChannelData[channel][sample] = mixedSample; + // Store for oscilloscope display auto pos = readPos.fetch_add (1); inputData[pos] = mixedSample; readPos = readPos % inputData.size(); @@ -313,15 +425,23 @@ class AudioExample yup::AudioDeviceManager deviceManager; std::vector> sineWaveGenerators; + // MIDI keyboard components + yup::MidiKeyboardState keyboardState; + yup::MidiKeyboardComponent keyboardComponent; + std::vector renderData; std::vector inputData; yup::CriticalSection renderMutex; std::atomic_int readPos = 0; yup::OwnedArray sliders; - int totalRows = 4; + int totalRows = 3; int totalColumns = 4; std::unique_ptr button; + std::unique_ptr clearButton; + std::unique_ptr volumeSlider; Oscilloscope oscilloscope; + + float masterVolume = 0.5f; }; diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index 1cd85118d..5c0338d91 100644 --- a/examples/graphics/source/main.cpp +++ b/examples/graphics/source/main.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #if YUP_MODULE_AVAILABLE_yup_python #include diff --git a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp index e08c2ae70..900cc9bd3 100644 --- a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp +++ b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp @@ -284,7 +284,9 @@ void MidiKeyboardComponent::getKeyPosition (int midiNoteNumber, float keyWidth, { jassert (midiNoteNumber >= 0 && midiNoteNumber < 128); - static const float blackKeyOffsets[] = { 0.0f, 0.6f, 0.0f, 0.7f, 0.0f, 0.0f, 0.6f, 0.0f, 0.65f, 0.0f, 0.7f, 0.0f }; + // Fixed black key offsets for proper positioning + // static const float blackKeyOffsets[] = { 0.0f, 0.25f, 0.0f, 0.35f, 0.0f, 0.0f, 0.25f, 0.0f, 0.3f, 0.0f, 0.35f, 0.0f }; + static const float blackKeyOffsets[] = { 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f }; auto octave = midiNoteNumber / 12; auto note = midiNoteNumber % 12; diff --git a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h index af95ccf25..6bdd5033c 100644 --- a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h +++ b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h @@ -34,8 +34,9 @@ namespace yup @tags{AudioGUI} */ -class YUP_API MidiKeyboardComponent : public Component, - public MidiKeyboardState::Listener +class YUP_API MidiKeyboardComponent + : public Component + , public MidiKeyboardState::Listener { public: //============================================================================== diff --git a/modules/yup_graphics/graphics/yup_Color.h b/modules/yup_graphics/graphics/yup_Color.h index f8af8084b..943abdc4e 100644 --- a/modules/yup_graphics/graphics/yup_Color.h +++ b/modules/yup_graphics/graphics/yup_Color.h @@ -777,6 +777,25 @@ class YUP_API Color return result; } + //============================================================================== + constexpr Color overlaidWith (Color src) const noexcept + { + auto destAlpha = getAlpha(); + if (destAlpha <= 0) + return src; + + auto invA = 0xff - static_cast (src.getAlpha()); + auto resA = 0xff - (((0xff - destAlpha) * invA) >> 8); + if (resA <= 0) + return *this; + + auto da = (invA * destAlpha) / resA; + return Color ((uint8) resA, + (uint8) (src.getRed() + ((((int) getRed() - src.getRed()) * da) >> 8)), + (uint8) (src.getGreen() + ((((int) getGreen() - src.getGreen()) * da) >> 8)), + (uint8) (src.getBlue() + ((((int) getBlue() - src.getBlue()) * da) >> 8))); + } + //============================================================================== // TODO - doxygen static Color opaqueRandom() noexcept diff --git a/modules/yup_graphics/graphics/yup_ColorGradient.h b/modules/yup_graphics/graphics/yup_ColorGradient.h index fff3a61d9..7f9e553bf 100644 --- a/modules/yup_graphics/graphics/yup_ColorGradient.h +++ b/modules/yup_graphics/graphics/yup_ColorGradient.h @@ -47,7 +47,7 @@ class YUP_API ColorGradient /** Constructs a default color stop with zero values. */ constexpr ColorStop() = default; - /** Constructs a color stop with the given color, x, y, and delta. */ + /** Constructs a color stop with the given color, x, y and delta. */ constexpr ColorStop (Color color, float x, float y, float delta) : color (color) , x (x) @@ -56,6 +56,15 @@ class YUP_API ColorGradient { } + /** Constructs a color stop with the given color, point and delta. */ + constexpr ColorStop (Color color, const Point& p, float delta) + : color (color) + , x (p.getX()) + , y (p.getY()) + , delta (delta) + { + } + constexpr ColorStop (const ColorStop& other) noexcept = default; constexpr ColorStop (ColorStop&& other) noexcept = default; constexpr ColorStop& operator= (const ColorStop& other) noexcept = default; @@ -94,6 +103,11 @@ class YUP_API ColorGradient radius = std::sqrt (square (x2 - x1) + square (y2 - y1)); } + ColorGradient (Color color1, const Point& p1, Color color2, const Point& p2, Type type) noexcept + : ColorGradient (color1, p1.getX(), p1.getY(), color2, p2.getX(), p2.getY(), type) + { + } + /** Constructs a gradient with multiple color stops. @param type The type of gradient (Linear or Radial). @@ -248,6 +262,11 @@ class YUP_API ColorGradient }); } + void addColorStop (Color color, const Point& p, float delta) + { + addColorStop (color, p.getX(), p.getY(), delta); + } + /** Clears all color stops. */ void clearStops() { diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index c9e91f7dd..9e813fdc6 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -594,6 +594,29 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe auto keyWidth = keyboard.getKeyStartRange().getLength() / getNumWhiteKeysInRange (keyboard.getLowestVisibleKey(), keyboard.getHighestVisibleKey() + 1); + // Draw keyboard background with subtle gradient shadow + auto keyboardWidth = keyboard.getKeyStartRange().getEnd(); + auto shadowColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyShadowColorId); + + if (! shadowColor.isTransparent()) + { + // Draw subtle top shadow gradient for depth + ColorGradient shadowGradient; + shadowGradient.addColorStop (shadowColor, Point (0.0f, 0.0f), 0.0f); + shadowGradient.addColorStop (shadowColor.withAlpha (0.0f), Point (0.0f, 5.0f), 1.0f); + + g.setFillColorGradient (shadowGradient); + g.fillRect (Rectangle (0.0f, 0.0f, keyboardWidth, 5.0f)); + } + + // Draw separator line at bottom + auto lineColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::keyOutlineColorId); + if (! lineColor.isTransparent()) + { + g.setFillColor (lineColor); + g.fillRect (Rectangle (0.0f, bounds.getHeight() - 1.0f, keyboardWidth, 1.0f)); + } + // Paint white keys first for (int note = keyboard.getLowestVisibleKey(); note <= keyboard.getHighestVisibleKey(); ++note) { @@ -606,49 +629,71 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe auto isPressed = keyboard.isNoteOn (note); auto isOver = keyboard.isMouseOverNote (note); - // Use theme colors - auto fillColor = isPressed ? ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyPressedColorId) - : ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyColorId); - - auto shadowColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyShadowColorId); + // Base colors from theme + auto whiteKeyColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyColorId); + auto pressedColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyPressedColorId); auto outlineColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::keyOutlineColorId); - // Draw 3D effect - auto shadowArea = keyArea.reduced (1.0f); - shadowArea.translate (2.0f, 2.0f); - - g.setFillColor (shadowColor); - g.fillRoundedRect (shadowArea, 2.0f); + // Determine fill color based on state + Color fillColor = whiteKeyColor; + if (isPressed) + fillColor = pressedColor; + if (isOver && ! isPressed) + fillColor = whiteKeyColor.overlaidWith (pressedColor.withAlpha (0.3f)); - // Main key + // Fill the key g.setFillColor (fillColor); - g.fillRoundedRect (keyArea.reduced (0.5f), 1.5f); + g.fillRect (keyArea); - // Highlight for 3D effect - if (! isPressed) + // Draw key separator line on the left edge + if (! outlineColor.isTransparent()) { - g.setFillColor (fillColor.brighter (0.3f)); - g.fillRoundedRect (keyArea.reduced (1.0f).removeFromTop (keyArea.getHeight() * 0.3f), 1.5f); - } + g.setFillColor (outlineColor); + g.fillRect (keyArea.removeFromLeft (1.0f)); - // Outline - g.setStrokeColor (outlineColor); - g.setStrokeWidth (1.0f); - g.strokeRoundedRect (keyArea.reduced (0.5f), 1.5f); + // Draw right edge for the last key + if (note == keyboard.getHighestVisibleKey()) + { + g.fillRect (keyArea.removeFromRight (1.0f).translated (keyArea.getWidth(), 0.0f)); + } + } - // Note text - /* - if (keyboard.getWidth() > 20 && keyArea.getWidth() > 20.0f) + // Draw note text if there's space + if (keyboard.getWidth() > 100 && keyArea.getWidth() > 15.0f) { - auto noteText = MidiKeyboardComponent::getWhiteNoteText (note); + auto noteText = String(); + int noteInOctave = note % 12; + switch (noteInOctave) + { + case 0: noteText = "C"; break; + case 2: noteText = "D"; break; + case 4: noteText = "E"; break; + case 5: noteText = "F"; break; + case 7: noteText = "G"; break; + case 9: noteText = "A"; break; + case 11: noteText = "B"; break; + default: break; + } + if (noteText.isNotEmpty()) { - g.setFillColor (outlineColor); - g.drawText (noteText, keyArea.reduced (2.0f).removeFromBottom (15.0f), - Justification::centred, false); + auto textColor = outlineColor.contrasting (0.8f); + if (isPressed) + textColor = pressedColor.contrasting (0.8f); + + g.setFillColor (textColor); + + StyledText styledText; + { + auto modifier = styledText.startUpdate(); + modifier.appendText (noteText, theme.getDefaultFont(), 11.0f); + modifier.setHorizontalAlign (StyledText::center); + } + + auto textArea = keyArea.reduced (2.0f).removeFromBottom (16.0f); + g.fillFittedText (styledText, textArea); } } - */ } } @@ -664,28 +709,40 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe auto isPressed = keyboard.isNoteOn (note); auto isOver = keyboard.isMouseOverNote (note); - // Use theme colors - auto fillColor = isPressed ? ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyPressedColorId) - : ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyColorId); - - auto shadowColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyShadowColorId); + // Base colors from theme + auto blackKeyColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyColorId); + auto blackPressedColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyPressedColorId); - // Draw 3D effect - auto shadowArea = keyArea.reduced (0.5f); - shadowArea.translate (1.5f, 1.5f); + // Determine fill color based on state + Color fillColor = blackKeyColor; + if (isPressed) + fillColor = blackPressedColor; + if (isOver && ! isPressed) + fillColor = blackKeyColor.overlaidWith (blackPressedColor.withAlpha (0.3f)); - g.setFillColor (shadowColor); - g.fillRoundedRect (shadowArea, 2.0f); - - // Main key + // Fill the key g.setFillColor (fillColor); - g.fillRoundedRect (keyArea.reduced (0.25f), 1.5f); + g.fillRect (keyArea); - // Highlight for 3D effect - if (! isPressed) + if (isPressed) { - g.setFillColor (fillColor.brighter (0.2f)); - g.fillRoundedRect (keyArea.reduced (1.0f).removeFromTop (keyArea.getHeight() * 0.2f), 1.5f); + // Draw pressed outline + g.setStrokeColor (blackKeyColor); + g.setStrokeWidth (1.0f); + g.strokeRect (keyArea); + } + else + { + // Draw 3D highlight effect for unpressed keys + auto highlightColor = fillColor.brighter (0.4f); + g.setFillColor (highlightColor); + + // Create highlight area - top portion and side edges + auto sideIndent = keyArea.getWidth() * 0.125f; + auto topIndent = keyArea.getHeight() * 0.875f; + auto highlightArea = keyArea.reduced (sideIndent, 0).removeFromTop (topIndent); + + g.fillRect (highlightArea); } } } @@ -734,3 +791,4 @@ ApplicationTheme::Ptr createThemeVersion1() } } // namespace yup + From dc9c9749224f36a7338855d2b19c11ab4f15ea30 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 16 Jul 2025 16:01:46 +0200 Subject: [PATCH 03/12] Improve midi keyboard component --- .../keyboard/yup_MidiKeyboardComponent.cpp | 75 +++++++++++-------- .../keyboard/yup_MidiKeyboardComponent.h | 3 +- .../themes/theme_v1/yup_ThemeVersion1.cpp | 19 +---- 3 files changed, 49 insertions(+), 48 deletions(-) diff --git a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp index 900cc9bd3..3b1e38f5d 100644 --- a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp +++ b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.cpp @@ -151,35 +151,49 @@ void MidiKeyboardComponent::mouseUp (const MouseEvent& e) if (! isEnabled()) return; - updateNoteUnderMouse (e, false); - - for (int i = mouseDownNotes.size(); --i >= 0;) - { - const int noteDown = mouseDownNotes.getUnchecked (i); + // Always release all notes that were triggered by mouse interaction + for (auto noteDown : mouseDownNotes) + state.noteOff (midiChannel, noteDown, velocity); - if (mouseDraggedToKey (noteDown, e)) - { - mouseDownNotes.remove (i); - } - else - { - state.noteOff (midiChannel, noteDown, velocity); - mouseDownNotes.remove (i); - } - } + mouseDownNotes.clear(); + // Update visual state to show keys are no longer pressed + updateNoteUnderMouse (e, false); updateShadowNoteUnderMouse (e); shouldCheckState = true; } +void MidiKeyboardComponent::mouseMove (const MouseEvent& e) +{ + if (! isEnabled()) + return; + + updateShadowNoteUnderMouse (e); +} + void MidiKeyboardComponent::mouseEnter (const MouseEvent& e) { updateShadowNoteUnderMouse (e); + + // If we're entering while dragging, trigger the note under the mouse + if (e.isAnyButtonDown()) + { + updateNoteUnderMouse (e, true); + } } void MidiKeyboardComponent::mouseExit (const MouseEvent& e) { updateShadowNoteUnderMouse (e); + + // If we're dragging and leaving the component, release all notes + if (e.isAnyButtonDown() && ! mouseDownNotes.isEmpty()) + { + for (auto noteDown : mouseDownNotes) + state.noteOff (midiChannel, noteDown, velocity); + + mouseDownNotes.clear(); + } } void MidiKeyboardComponent::mouseWheel (const MouseEvent&, const MouseWheelData& wheel) @@ -429,6 +443,7 @@ void MidiKeyboardComponent::updateNoteUnderMouse (Point pos, bool isDown, auto newNote = xyToNote (pos, mousePositionVelocity); auto oldNote = mouseOverNote; + // Always update hover visual state when the note under mouse changes if (oldNote != newNote) { repaintNote (oldNote); @@ -438,19 +453,24 @@ void MidiKeyboardComponent::updateNoteUnderMouse (Point pos, bool isDown, if (isDown) { - if (newNote != oldNote) + // Handle note triggering - this should work regardless of hover state + + // First, release any previously pressed notes that are no longer under the mouse + for (int i = mouseDownNotes.size(); --i >= 0;) { - if (oldNote >= 0) + auto pressedNote = mouseDownNotes.getUnchecked (i); + if (pressedNote != newNote) { - mouseDownNotes.removeFirstMatchingValue (oldNote); - state.noteOff (midiChannel, oldNote, mousePositionVelocity); + state.noteOff (midiChannel, pressedNote, mousePositionVelocity); + mouseDownNotes.remove (i); } + } - if (newNote >= 0 && ! mouseDownNotes.contains (newNote)) - { - state.noteOn (midiChannel, newNote, mousePositionVelocity); - mouseDownNotes.add (newNote); - } + // Then, trigger the new note if it's valid and not already pressed + if (newNote >= 0 && ! mouseDownNotes.contains (newNote)) + { + state.noteOn (midiChannel, newNote, mousePositionVelocity); + mouseDownNotes.add (newNote); } } } @@ -460,13 +480,6 @@ void MidiKeyboardComponent::updateNoteUnderMouse (const MouseEvent& e, bool isDo updateNoteUnderMouse (e.getPosition(), isDown, 0); } -bool MidiKeyboardComponent::mouseDraggedToKey (int midiNoteNumber, const MouseEvent& e) -{ - jassert (midiNoteNumber >= 0 && midiNoteNumber < 128); - - return getRectangleForKey (midiNoteNumber).contains (e.getPosition()); -} - void MidiKeyboardComponent::resetAnyKeysInUse() { if (! mouseDownNotes.isEmpty()) diff --git a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h index 6bdd5033c..6d8b179a6 100644 --- a/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h +++ b/modules/yup_audio_gui/keyboard/yup_MidiKeyboardComponent.h @@ -163,6 +163,8 @@ class YUP_API MidiKeyboardComponent /** @internal */ void mouseUp (const MouseEvent& e) override; /** @internal */ + void mouseMove (const MouseEvent& e) override; + /** @internal */ void mouseEnter (const MouseEvent& e) override; /** @internal */ void mouseExit (const MouseEvent& e) override; @@ -209,7 +211,6 @@ class YUP_API MidiKeyboardComponent void repaintNote (int midiNoteNumber); void updateNoteUnderMouse (Point pos, bool isDown, int fingerNum); void updateNoteUnderMouse (const MouseEvent& e, bool isDown); - bool mouseDraggedToKey (int midiNoteNumber, const MouseEvent& e); void resetAnyKeysInUse(); void updateShadowNoteUnderMouse (const MouseEvent& e); diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index 9e813fdc6..006f14544 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -580,19 +580,8 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe if (bounds.isEmpty()) return; - auto getNumWhiteKeysInRange = [](int rangeStart, int rangeEnd) - { - int numWhiteKeys = 0; - - for (int i = rangeStart; i < rangeEnd; ++i) - if (! MidiMessage::isMidiNoteBlack (i)) - ++numWhiteKeys; - - return numWhiteKeys; - }; - - auto keyWidth = keyboard.getKeyStartRange().getLength() / getNumWhiteKeysInRange (keyboard.getLowestVisibleKey(), - keyboard.getHighestVisibleKey() + 1); + auto keyWidth = keyboard.getKeyStartRange().getLength(); + keyWidth /= keyboard.getNumWhiteKeysInRange (keyboard.getLowestVisibleKey(), keyboard.getHighestVisibleKey() + 1); // Draw keyboard background with subtle gradient shadow auto keyboardWidth = keyboard.getKeyStartRange().getEnd(); @@ -653,9 +642,7 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe // Draw right edge for the last key if (note == keyboard.getHighestVisibleKey()) - { g.fillRect (keyArea.removeFromRight (1.0f).translated (keyArea.getWidth(), 0.0f)); - } } // Draw note text if there's space @@ -782,7 +769,7 @@ ApplicationTheme::Ptr createThemeVersion1() theme->setColor (MidiKeyboardComponent::Style::whiteKeyPressedColorId, Color (0xff4ebfff)); theme->setColor (MidiKeyboardComponent::Style::whiteKeyShadowColorId, Color (0x40000000)); theme->setColor (MidiKeyboardComponent::Style::blackKeyColorId, Color (0xff2a2a2a)); - theme->setColor (MidiKeyboardComponent::Style::blackKeyPressedColorId, Color (0xff7a7aff)); + theme->setColor (MidiKeyboardComponent::Style::blackKeyPressedColorId, Color (0xff4ebfff)); theme->setColor (MidiKeyboardComponent::Style::blackKeyShadowColorId, Color (0x80000000)); theme->setColor (MidiKeyboardComponent::Style::keyOutlineColorId, Color (0xff888888)); #endif From 82cfb41bfd998ac62ba7dcda5bc7fccc3deb9078 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 16 Jul 2025 17:23:13 +0200 Subject: [PATCH 04/12] More tweaks --- examples/graphics/source/examples/Audio.h | 406 +++++++++++++----- .../yup_graphics/graphics/yup_Graphics.cpp | 53 +++ modules/yup_graphics/graphics/yup_Graphics.h | 20 + .../themes/theme_v1/yup_ThemeVersion1.cpp | 23 +- modules/yup_gui/widgets/yup_Label.cpp | 6 +- modules/yup_gui/widgets/yup_Label.h | 6 +- .../bindings/yup_YupGraphics_bindings.cpp | 7 +- 7 files changed, 399 insertions(+), 122 deletions(-) diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index 2327ba6d9..67301ec05 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -26,10 +26,10 @@ //============================================================================== -class SineWaveGenerator +class HarmonicSineGenerator { public: - SineWaveGenerator() + HarmonicSineGenerator() : sampleRate (44100.0) , currentAngle (0.0) , frequency (0.0) @@ -40,17 +40,13 @@ class SineWaveGenerator void setSampleRate (double newSampleRate) { sampleRate = newSampleRate; - - frequency.reset (newSampleRate, 0.1); - amplitude.reset (newSampleRate, 0.1); + frequency.reset (newSampleRate, 0.05); + amplitude.reset (newSampleRate, 0.02); } - void setFrequency (double newFrequency, bool immediate = false) + void setFrequency (double newFrequency) { - if (immediate) - frequency.setCurrentAndTargetValue ((yup::MathConstants::twoPi * newFrequency) / sampleRate); - else - frequency.setTargetValue ((yup::MathConstants::twoPi * newFrequency) / sampleRate); + frequency.setTargetValue ((yup::MathConstants::twoPi * newFrequency) / sampleRate); } void setAmplitude (float newAmplitude) @@ -58,7 +54,7 @@ class SineWaveGenerator amplitude.setTargetValue (newAmplitude); } - float getAmplitude() const + float getCurrentAmplitude() const { return amplitude.getCurrentValue(); } @@ -83,6 +79,160 @@ class SineWaveGenerator //============================================================================== +class HarmonicSynth +{ +public: + HarmonicSynth() + : isNoteOn (false) + , currentNote (-1) + , fundamentalFrequency (0.0) + , masterAmplitude (0.5f) + { + // Initialize harmonic generators + const int numHarmonics = 16; // 4x4 grid + harmonicGenerators.resize (numHarmonics); + harmonicMultipliers.resize (numHarmonics); + harmonicAmplitudes.resize (numHarmonics); + + for (int i = 0; i < numHarmonics; ++i) + { + harmonicGenerators[i] = std::make_unique(); + + // Set up harmonic relationships (1st, 2nd, 3rd harmonic, etc., plus some non-integer ratios) + if (i < 8) + harmonicMultipliers[i] = (i + 1); // 1x, 2x, 3x, 4x, 5x, 6x, 7x, 8x + else + harmonicMultipliers[i] = (i - 7) * 0.5 + 0.5; // 0.5x, 1x, 1.5x, 2x, 2.5x, 3x, 3.5x, 4x + + harmonicAmplitudes[i] = 0.0f; // Start silent + } + } + + void setSampleRate (double newSampleRate) + { + for (auto& generator : harmonicGenerators) + generator->setSampleRate (newSampleRate); + } + + void noteOn (int midiNoteNumber, float velocity) + { + currentNote = midiNoteNumber; + isNoteOn = true; + + // Convert MIDI note to frequency: f = 440 * 2^((n-69)/12) + fundamentalFrequency = 440.0 * std::pow (2.0, (midiNoteNumber - 69) / 12.0); + + updateHarmonicFrequencies(); + updateHarmonicAmplitudes (velocity); + } + + void noteOff (int midiNoteNumber) + { + if (currentNote == midiNoteNumber) + { + isNoteOn = false; + for (auto& generator : harmonicGenerators) + generator->setAmplitude (0.0f); + } + } + + void allNotesOff() + { + isNoteOn = false; + currentNote = -1; + for (auto& generator : harmonicGenerators) + generator->setAmplitude (0.0f); + } + + void setHarmonicAmplitude (int harmonicIndex, float amplitude) + { + if (harmonicIndex >= 0 && harmonicIndex < harmonicAmplitudes.size()) + { + harmonicAmplitudes[harmonicIndex] = amplitude; + if (isNoteOn) + updateHarmonicAmplitudes (1.0f); // Use current velocity + } + } + + void setMasterAmplitude (float newAmplitude) + { + masterAmplitude = newAmplitude; + if (isNoteOn) + updateHarmonicAmplitudes (1.0f); + } + + float getMasterAmplitude() const + { + return masterAmplitude; + } + + bool isPlaying() const + { + if (!isNoteOn) return false; + + for (const auto& generator : harmonicGenerators) + { + if (generator->getCurrentAmplitude() > 0.001f) + return true; + } + return false; + } + + int getCurrentNote() const + { + return currentNote; + } + + float getNextSample() + { + float mixedSample = 0.0f; + + for (auto& generator : harmonicGenerators) + { + mixedSample += generator->getNextSample(); + } + + return mixedSample * masterAmplitude; + } + + double getHarmonicMultiplier (int index) const + { + if (index >= 0 && index < harmonicMultipliers.size()) + return harmonicMultipliers[index]; + return 1.0; + } + +private: + void updateHarmonicFrequencies() + { + for (size_t i = 0; i < harmonicGenerators.size(); ++i) + { + double harmonicFreq = fundamentalFrequency * harmonicMultipliers[i]; + harmonicGenerators[i]->setFrequency (harmonicFreq); + } + } + + void updateHarmonicAmplitudes (float velocity) + { + for (size_t i = 0; i < harmonicGenerators.size(); ++i) + { + float amplitude = harmonicAmplitudes[i] * velocity * masterAmplitude; + harmonicGenerators[i]->setAmplitude (amplitude); + } + } + + std::vector> harmonicGenerators; + std::vector harmonicMultipliers; + std::vector harmonicAmplitudes; + + bool isNoteOn; + int currentNote; + double fundamentalFrequency; + float masterAmplitude; +}; + +//============================================================================== + class Oscilloscope : public yup::Component { public: @@ -166,18 +316,9 @@ class AudioExample // Initialize the audio device deviceManager.initialiseWithDefaultDevices (0, 2); - // Initialize sine wave generators for MIDI notes (128 possible notes) + // Initialize harmonic synthesizer double sampleRate = deviceManager.getAudioDeviceSetup().sampleRate; - sineWaveGenerators.resize (128); - for (int i = 0; i < 128; ++i) - { - sineWaveGenerators[i] = std::make_unique(); - sineWaveGenerators[i]->setSampleRate (sampleRate); - // Convert MIDI note to frequency: f = 440 * 2^((n-69)/12) - double frequency = 440.0 * std::pow (2.0, (i - 69) / 12.0); - sineWaveGenerators[i]->setFrequency (frequency, true); - sineWaveGenerators[i]->setAmplitude (0.0f); // Start silent - } + harmonicSynth.setSampleRate (sampleRate); // Set up MIDI keyboard keyboardState.addListener (this); @@ -187,54 +328,102 @@ class AudioExample keyboardComponent.setVelocity (0.7f); addAndMakeVisible (keyboardComponent); - // Add sliders for manual control (reduced number for layout) + // Create title and subtitle labels + titleLabel = std::make_unique ("Title"); + titleLabel->setText ("YUP Harmonic Synthesizer"); + //titleLabel->setJustification (yup::Justification::centred); + //titleLabel->setFont (16.0f); + titleLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::white); + addAndMakeVisible (*titleLabel); + + subtitleLabel = std::make_unique ("Subtitle"); + subtitleLabel->setText ("Each knob controls a harmonic of the played note - experiment to create rich tones!"); + //subtitleLabel->setJustification (yup::Justification::centred); + //subtitleLabel->setFont (12.0f); + subtitleLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::white); + addAndMakeVisible (*subtitleLabel); + + // Create note indicator label + noteIndicatorLabel = std::make_unique ("NoteIndicator"); + noteIndicatorLabel->setText (""); + //noteIndicatorLabel->setJustification (yup::Justification::centred); + //noteIndicatorLabel->setFont (12.0f); + noteIndicatorLabel->setColor (yup::Label::Style::textFillColorId, yup::Colors::black); + noteIndicatorLabel->setColor (yup::Label::Style::backgroundColorId, yup::Colors::yellow.withAlpha (0.8f)); + addChildComponent (*noteIndicatorLabel); + + // Add harmonic control sliders (4x4 grid) for (int i = 0; i < totalRows * totalColumns; ++i) { auto slider = sliders.add (std::make_unique (yup::String (i))); - slider->onValueChanged = [this, i, sampleRate] (float value) + // Configure slider range and default value + slider->setRange ({0.0f, 1.0f}); + slider->setDefaultValue (0.0f); + + slider->onValueChanged = [this, i] (float value) { - // Map sliders to a specific range of notes for demonstration - int noteNumber = 60 + i; // Start from middle C - if (noteNumber < 128) - { - double baseFreq = 440.0 * std::pow (2.0, (noteNumber - 69) / 12.0); - sineWaveGenerators[noteNumber]->setFrequency (baseFreq * (1.0 + value * 0.5)); - sineWaveGenerators[noteNumber]->setAmplitude (value * 0.3f); - } + harmonicSynth.setHarmonicAmplitude (i, value * 0.4f); // Scale down to prevent clipping }; addAndMakeVisible (slider); + + // Create harmonic labels for each slider + auto label = harmonicLabels.add (std::make_unique (yup::String ("HarmonicLabel") + yup::String (i))); + //label->setJustificationType (yup::Justification::centred); + //label->setFont (10.0f); + label->setColor (yup::Label::Style::textFillColorId, yup::Colors::lightgray); + + // Set the harmonic multiplier text + auto multiplier = harmonicSynth.getHarmonicMultiplier (i); + label->setText (yup::String (multiplier, 1) + "x", yup::dontSendNotification); + + addAndMakeVisible (*label); } // Add buttons - button = std::make_unique ("Randomize"); - button->onClick = [this] + randomizeButton = std::make_unique ("Randomize"); + randomizeButton->onClick = [this] { for (int i = 0; i < sliders.size(); ++i) sliders[i]->setValue (yup::Random::getSystemRandom().nextFloat()); }; - addAndMakeVisible (*button); + addAndMakeVisible (*randomizeButton); // Add clear all notes button - clearButton = std::make_unique ("Clear All Notes"); + clearButton = std::make_unique ("All Notes Off"); clearButton->onClick = [this] { keyboardState.allNotesOff (0); // Turn off all notes on all channels + harmonicSynth.allNotesOff(); }; addAndMakeVisible (*clearButton); - // Add the oscilloscope - addAndMakeVisible (oscilloscope); - // Add volume control volumeSlider = std::make_unique ("Volume"); + + // Configure slider range and default value + volumeSlider->setRange ({0.0f, 1.0f}); + volumeSlider->setDefaultValue (0.5f); + volumeSlider->onValueChanged = [this] (float value) { masterVolume = value; }; volumeSlider->setValue (0.5f); // Set initial volume to 50% addAndMakeVisible (*volumeSlider); + + // Add the oscilloscope + addAndMakeVisible (oscilloscope); + + // Set some initial harmonic values for a nice sound + if (sliders.size() >= 4) + { + sliders[0]->setValue (0.8f); // Fundamental + sliders[1]->setValue (0.4f); // 2nd harmonic + sliders[2]->setValue (0.2f); // 3rd harmonic + sliders[3]->setValue (0.1f); // 4th harmonic + } } ~AudioExample() override @@ -248,6 +437,16 @@ class AudioExample { auto bounds = getLocalBounds(); + // Title area at the top + auto titleHeight = proportionOfHeight (0.05f); + auto titleBounds = bounds.removeFromTop (titleHeight); + titleLabel->setBounds (titleBounds); + + // Subtitle area + auto subtitleHeight = proportionOfHeight (0.03f); + auto subtitleBounds = bounds.removeFromTop (subtitleHeight); + subtitleLabel->setBounds (subtitleBounds); + // Reserve space for MIDI keyboard at the bottom auto keyboardHeight = proportionOfHeight (0.20f); auto keyboardBounds = bounds.removeFromBottom (keyboardHeight); @@ -258,14 +457,13 @@ class AudioExample auto oscilloscopeBounds = bounds.removeFromBottom (oscilloscopeHeight); oscilloscope.setBounds (oscilloscopeBounds.reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); - // Reserve space for buttons at the top - bounds.removeFromTop (proportionOfHeight (0.1f)); - auto buttonHeight = proportionOfHeight (0.10f); - auto buttonArea = bounds.removeFromTop (buttonHeight); + // Reserve space for buttons area + auto buttonHeight = proportionOfHeight (0.08f); + auto buttonArea = bounds.removeFromBottom (buttonHeight); auto buttonWidth = buttonArea.getWidth() / 3; - if (button != nullptr) - button->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); + if (randomizeButton != nullptr) + randomizeButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); if (clearButton != nullptr) clearButton->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); @@ -273,53 +471,39 @@ class AudioExample if (volumeSlider != nullptr) volumeSlider->setBounds (buttonArea.removeFromLeft (buttonWidth).reduced (proportionOfWidth (0.01f), proportionOfHeight (0.01f))); - // Use remaining space for sliders - auto sliderBounds = bounds.reduced (proportionOfWidth (0.1f), proportionOfHeight (0.05f)); + // Use remaining space for harmonic control sliders with labels + auto sliderBounds = bounds.reduced (proportionOfWidth (0.05f), proportionOfHeight (0.02f)); auto width = sliderBounds.getWidth() / totalColumns; auto height = sliderBounds.getHeight() / totalRows; - for (int i = 0; i < totalRows && sliders.size(); ++i) + for (int i = 0; i < totalRows && i * totalColumns < sliders.size(); ++i) { auto row = sliderBounds.removeFromTop (height); - for (int j = 0; j < totalColumns; ++j) + for (int j = 0; j < totalColumns && i * totalColumns + j < sliders.size(); ++j) { auto col = row.removeFromLeft (width); - sliders.getUnchecked (i * totalColumns + j)->setBounds (col.largestFittingSquare()); + auto harmonicIndex = i * totalColumns + j; + + // Reserve space for label at bottom of column + auto labelHeight = 20; + auto labelBounds = col.removeFromBottom (labelHeight); + harmonicLabels[harmonicIndex]->setBounds (labelBounds); + + // Use remaining space for slider - make it rectangular for slider appearance + auto sliderArea = col.largestFittingSquare(); + sliders.getUnchecked (harmonicIndex)->setBounds (sliderArea); } } + + // Position note indicator at bottom left + auto noteIndicatorBounds = yup::Rectangle (10, getHeight() - 40, 200, 30); + noteIndicatorLabel->setBounds (noteIndicatorBounds); } void paint (yup::Graphics& g) override { g.setFillColor (findColor (yup::DocumentWindow::Style::backgroundColorId).value_or (yup::Colors::dimgray)); g.fillAll(); - - // Draw some labels - auto bounds = getLocalBounds(); - auto titleArea = bounds.removeFromTop (proportionOfHeight (0.05f)); - auto subtitleArea = bounds.removeFromTop (proportionOfHeight (0.03f)); - - yup::StyledText titleText; - { - auto modifier = titleText.startUpdate(); - modifier.setMaxSize (titleArea.getSize()); - modifier.setHorizontalAlign (yup::StyledText::center); - modifier.appendText ("YUP Audio Synthesis Example with MIDI Keyboard", - yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(), 16.0f); - } - - yup::StyledText subtitleText; - { - auto modifier = subtitleText.startUpdate(); - modifier.setMaxSize (subtitleArea.getSize()); - modifier.setHorizontalAlign (yup::StyledText::center); - modifier.appendText ("Use the MIDI keyboard below or adjust sliders to generate tones", - yup::ApplicationTheme::getGlobalTheme()->getDefaultFont(), 12.0f); - } - - g.setFillColor (yup::Colors::white); - g.fillFittedText (titleText, titleArea); - g.fillFittedText (subtitleText, subtitleArea); } void mouseDown (const yup::MouseEvent& event) override @@ -336,23 +520,32 @@ class AudioExample if (oscilloscope.isVisible()) oscilloscope.repaint(); + + // Update note indicator + if (harmonicSynth.isPlaying()) + { + if (!noteIndicatorLabel->isVisible()) + { + noteIndicatorLabel->setVisible (true); + } + auto noteText = yup::String ("Playing Note: ") + yup::String (harmonicSynth.getCurrentNote()); + noteIndicatorLabel->setText (noteText, yup::dontSendNotification); + } + else + { + noteIndicatorLabel->setVisible (false); + } } // MIDI keyboard event handlers void handleNoteOn (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override { - if (midiNoteNumber >= 0 && midiNoteNumber < 128) - { - sineWaveGenerators[midiNoteNumber]->setAmplitude (velocity * 0.5f); - } + harmonicSynth.noteOn (midiNoteNumber, velocity); } void handleNoteOff (yup::MidiKeyboardState* source, int midiChannel, int midiNoteNumber, float velocity) override { - if (midiNoteNumber >= 0 && midiNoteNumber < 128) - { - sineWaveGenerators[midiNoteNumber]->setAmplitude (0.0f); - } + harmonicSynth.noteOff (midiNoteNumber); } void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, @@ -364,37 +557,22 @@ class AudioExample { for (int sample = 0; sample < numSamples; ++sample) { - float mixedSample = 0.0f; - int activeNotes = 0; + // Generate the next sample from the harmonic synth + float synthSample = harmonicSynth.getNextSample(); - // Mix all active MIDI notes - for (int i = 0; i < 128; ++i) - { - float amplitude = sineWaveGenerators[i]->getAmplitude(); - if (amplitude > 0.001f) // Only process notes that are actually sounding - { - mixedSample += sineWaveGenerators[i]->getNextSample(); - activeNotes++; - } - } - - // Apply master volume and normalize if multiple notes are playing - if (activeNotes > 0) - { - mixedSample *= masterVolume; - if (activeNotes > 1) - mixedSample /= std::sqrt (static_cast (activeNotes)); // Gentle normalization - } + // Apply master volume + synthSample *= masterVolume; // Apply soft limiting to prevent clipping - mixedSample = std::tanh (mixedSample); + synthSample = std::tanh (synthSample); + // Output to all channels for (int channel = 0; channel < numOutputChannels; ++channel) - outputChannelData[channel][sample] = mixedSample; + outputChannelData[channel][sample] = synthSample; // Store for oscilloscope display auto pos = readPos.fetch_add (1); - inputData[pos] = mixedSample; + inputData[pos] = synthSample; readPos = readPos % inputData.size(); } @@ -423,7 +601,7 @@ class AudioExample private: yup::AudioDeviceManager deviceManager; - std::vector> sineWaveGenerators; + HarmonicSynth harmonicSynth; // MIDI keyboard components yup::MidiKeyboardState keyboardState; @@ -434,11 +612,17 @@ class AudioExample yup::CriticalSection renderMutex; std::atomic_int readPos = 0; + // UI Components + std::unique_ptr titleLabel; + std::unique_ptr subtitleLabel; + std::unique_ptr noteIndicatorLabel; + yup::OwnedArray sliders; - int totalRows = 3; + yup::OwnedArray harmonicLabels; + int totalRows = 4; int totalColumns = 4; - std::unique_ptr button; + std::unique_ptr randomizeButton; std::unique_ptr clearButton; std::unique_ptr volumeSlider; Oscilloscope oscilloscope; diff --git a/modules/yup_graphics/graphics/yup_Graphics.cpp b/modules/yup_graphics/graphics/yup_Graphics.cpp index 01ca3125c..179e70d24 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.cpp +++ b/modules/yup_graphics/graphics/yup_Graphics.cpp @@ -170,6 +170,31 @@ rive::rcp toColorGradient (rive::Factory& factory, const Col } } +//============================================================================== +StyledText::HorizontalAlign toHorizontalAlign (Justification justification) +{ + if (justification.testFlags (Justification::left)) + return StyledText::left; + else if (justification.testFlags (Justification::right)) + return StyledText::right; + else if (justification.testFlags (Justification::horizontalCenter)) + return StyledText::center; + else + return StyledText::center; +} + +StyledText::VerticalAlign toVerticalAlign (Justification justification) +{ + if (justification.testFlags (Justification::top)) + return StyledText::top; + else if (justification.testFlags (Justification::bottom)) + return StyledText::bottom; + else if (justification.testFlags (Justification::verticalCenter)) + return StyledText::middle; + else + return StyledText::middle; +} + } // namespace //============================================================================== @@ -679,6 +704,20 @@ void Graphics::fillFittedText (const StyledText& text, const Rectangle& r renderFittedText (text, rect, std::addressof (paint)); } +void Graphics::fillFittedText (const String& text, const Font& font, float fontSize, const Rectangle& rect, Justification justification) +{ + StyledText styledText; + { + auto modifier = styledText.startUpdate(); + modifier.setMaxSize (rect.getSize()); + modifier.appendText (text, font, fontSize); + modifier.setHorizontalAlign (toHorizontalAlign (justification)); + modifier.setVerticalAlign (toVerticalAlign (justification)); + } + + fillFittedText (styledText, rect); +} + void Graphics::strokeFittedText (const StyledText& text, const Rectangle& rect) { jassert (! text.needsUpdate()); @@ -702,6 +741,20 @@ void Graphics::strokeFittedText (const StyledText& text, const Rectangle& renderFittedText (text, rect, std::addressof (paint)); } +void Graphics::strokeFittedText (const String& text, const Font& font, float fontSize, const Rectangle& rect, Justification justification) +{ + StyledText styledText; + { + auto modifier = styledText.startUpdate(); + modifier.setMaxSize (rect.getSize()); + modifier.appendText (text, font, fontSize); + modifier.setHorizontalAlign (toHorizontalAlign (justification)); + modifier.setVerticalAlign (toVerticalAlign (justification)); + } + + strokeFittedText (styledText, rect); +} + void Graphics::renderFittedText (const StyledText& text, const Rectangle& rect, rive::RiveRenderPaint* paint) { jassert (! text.needsUpdate()); diff --git a/modules/yup_graphics/graphics/yup_Graphics.h b/modules/yup_graphics/graphics/yup_Graphics.h index 060dedbab..bca476200 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.h +++ b/modules/yup_graphics/graphics/yup_Graphics.h @@ -452,6 +452,16 @@ class YUP_API Graphics */ void fillFittedText (const StyledText& text, const Rectangle& rect); + /** Fills a text with a specified font and size. + + @param text The text to fill. + @param rect The rectangle that defines the text. + @param font The font to use. + @param fontSize The size of the font. + @param justification The justification of the text. + */ + void fillFittedText (const String& text, const Font& font, float fontSize, const Rectangle& rect, Justification justification = Justification::center); + /** Draws an attributed text. @param text The text to draw. @@ -459,6 +469,16 @@ class YUP_API Graphics */ void strokeFittedText (const StyledText& text, const Rectangle& rect); + /** Draws a text with a specified font and size. + + @param text The text to draw. + @param rect The rectangle that defines the text. + @param font The font to use. + @param fontSize The size of the font. + @param justification The justification of the text. + */ + void strokeFittedText (const String& text, const Font& font, float fontSize, const Rectangle& rect, Justification justification = Justification::center); + //============================================================================== /** Retrieves the global context scale, the one used to construct the graphics instance. */ float getContextScale() const; diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index 006f14544..e124919e2 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -364,7 +364,20 @@ void paintLabel (Graphics& g, const ApplicationTheme& theme, const Label& l) auto& styledText = l.getStyledText(); const auto bounds = l.getLocalBounds(); - if (const auto strokeColor = l.findColor (Label::Style::strokeColorId); strokeColor && ! strokeColor->isTransparent()) + if (const auto backgroundColor = l.findColor (Label::Style::backgroundColorId); backgroundColor && ! backgroundColor->isTransparent()) + { + g.setFillColor (*backgroundColor); + g.fillRoundedRect (bounds, 4.0f); + } + + if (const auto outlineColor = l.findColor (Label::Style::outlineColorId); outlineColor && ! outlineColor->isTransparent()) + { + g.setStrokeColor (*outlineColor); + g.setStrokeWidth (2.0f); + g.strokeRoundedRect (bounds, 4.0f); + } + + if (const auto strokeColor = l.findColor (Label::Style::textStrokeColorId); strokeColor && ! strokeColor->isTransparent()) { g.setStrokeColor (*strokeColor); g.setStrokeWidth (l.getStrokeWidth()); @@ -373,7 +386,7 @@ void paintLabel (Graphics& g, const ApplicationTheme& theme, const Label& l) if (! styledText.isEmpty()) { - const auto fillColor = l.findColor (Label::Style::fillColorId).value_or (Colors::white); + const auto fillColor = l.findColor (Label::Style::textFillColorId).value_or (Colors::white); g.setFillColor (fillColor); g.fillFittedText (styledText, bounds); } @@ -758,8 +771,10 @@ ApplicationTheme::Ptr createThemeVersion1() theme->setComponentStyle (ComponentStyle::createStyle (paintComboBox)); theme->setComponentStyle