diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e40ae..f4becac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Ver ### Added - Three-string polyphony: up to three notes at once as separate feedback loops, with Coupling between them. -- The STRINGS tab, with POLY, LOCK, Coupling, Interval 2 and Interval 3. LOCK chooses between a tuned chord and the free drone. +- POLY, LOCK, Coupling, Interval 2 and Interval 3. LOCK chooses between a tuned chord and the free drone. - Three presets: Open Fifths, Sympathetic and Chord Keys. -- Echo Pong: a second echo, off by default, whose repeats bounce between left and right and saturate, with PONG, Drive, Tone, Width and Level in its own PONG tab. +- Echo Pong: a second echo, off by default, whose repeats bounce between left and right and saturate, with PONG, Drive, Tone, Width and Level. - The Ember Pong preset. +### Changed + +- A new window, on one page with no tabs: the plant in the centre with Frequency and Body beside it, one row of FILTER, + REVERB, PONG and ECHO below, and a SETUP panel for Strings, Output and MIDI. Red marks the controls inside the + feedback loop, amber those after it. +- The plant is drawn as a sundew whose tentacles unfurl with the sound, and marks the strings' notes under POLY. +- An arc over Highpass and Lowpass draws the band that survives in the loop, and one over Tone draws the echo's band-pass. + A dot beside Echo Time blinks at the real time between repeats. +- The window scales from 75 % to 150 %, and the scale is saved with the project in place of the open tab. +- Interval readouts show the interval a voice really plays when the note range holds it back, tagged LIMIT. +- The interface's fonts, Inter and Cormorant Garamond, are embedded, under the SIL Open Font License. + ### Fixed - The echo's first repeats after loading, or after the engine recovers from a bad sample, came late and warped: diff --git a/CLAUDE.md b/CLAUDE.md index 6ae2334..d29d700 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,9 +37,10 @@ tools/hardware-comparison/check_reference.sh build/render_cli | `src/plugin/PluginEditor.*` | The window: assembles the interface and runs its timer | | `src/plugin/EditorBridge.h` | What the editor reads from the processor, beyond parameters | | `src/plugin/SpectrumTap.h` | Lock-free FIFO from the audio thread to the display | -| `src/plugin/ui/` | Theme, knob drawing, plant, tabs. `SpectrumBands.h` and `Readouts.h` have no JUCE, so tests use them alone | +| `src/plugin/ui/` | Theme and fonts, knob drawing, plant, header, control row, setup panel. `SpectrumBands.h`, `Readouts.h`, `ResponseArcs.h` and `EchoPulse.h` have no JUCE, so tests use them alone | | `src/plugin/Presets.h` | Factory presets, plain C++ so tests can use them | | `tests/` | `render_test`, `voice_allocator_test`, `ui_test`, `plugin_host_test`, `editor_snapshot` (draws the window to a PNG), measuring tools | +| `assets/fonts/` | The interface's fonts, embedded as binary data, with their licences | | `third_party/` | Vendored DaisySP | | `tools/hardware-comparison/` | Comparison against a real unit, and the reference checksums | | `docs/superpowers/` | Design specs and implementation plans | diff --git a/CMakeLists.txt b/CMakeLists.txt index a4c5b3c..acdb742 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,12 +51,21 @@ if(DROSERA_BUILD_PLUGIN) GIT_SHALLOW ON) FetchContent_MakeAvailable(JUCE) + # The interface's fonts, both under the SIL Open Font License (assets/fonts/OFL-*.txt) + juce_add_binary_data(DroseraFonts SOURCES + assets/fonts/CormorantGaramond-Medium.otf + assets/fonts/Inter-Regular.ttf + assets/fonts/Inter-Medium.ttf) + set(DROSERA_PLUGIN_SOURCES src/plugin/PluginProcessor.cpp src/plugin/PluginEditor.cpp src/plugin/ui/DroseraLookAndFeel.cpp + src/plugin/ui/LabeledKnob.cpp src/plugin/ui/PlantView.cpp - src/plugin/ui/TabPanel.cpp) + src/plugin/ui/ControlRow.cpp + src/plugin/ui/HeaderBar.cpp + src/plugin/ui/SetupPanel.cpp) juce_add_plugin(Drosera COMPANY_NAME "codeskine" @@ -74,6 +83,7 @@ if(DROSERA_BUILD_PLUGIN) JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 JUCE_VST3_CAN_REPLACE_VST2=0) target_link_libraries(Drosera PRIVATE DroseraDSP + DroseraFonts juce::juce_audio_utils juce::juce_dsp juce::juce_recommended_config_flags @@ -105,7 +115,7 @@ if(DROSERA_BUILD_PLUGIN) tests/editor_snapshot.cpp ${DROSERA_PLUGIN_SOURCES}) target_compile_definitions(editor_snapshot PRIVATE JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0) target_include_directories(editor_snapshot PRIVATE src/plugin) - target_link_libraries(editor_snapshot PRIVATE DroseraDSP + target_link_libraries(editor_snapshot PRIVATE DroseraDSP DroseraFonts juce::juce_audio_utils juce::juce_dsp juce::juce_recommended_config_flags) # The interface's logic and the editor's behaviour (see tests/ui_test.cpp) @@ -114,6 +124,6 @@ if(DROSERA_BUILD_PLUGIN) target_include_directories(ui_test PRIVATE tests src/plugin) target_compile_definitions(ui_test PRIVATE JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0 DROSERA_STATE_FIXTURE="${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/state-baseline.bin") - target_link_libraries(ui_test PRIVATE DroseraDSP + target_link_libraries(ui_test PRIVATE DroseraDSP DroseraFonts juce::juce_audio_utils juce::juce_dsp juce::juce_recommended_config_flags) endif() diff --git a/README.md b/README.md index e9f3d4a..6a077f5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Drosera -![Drosera's interface: a sundew whose tentacles show the sound's spectrum, with Feedback at its heart](docs/images/drosera.png) +![Drosera's window: a sundew whose tentacles show the sound's spectrum, Feedback at its heart, Frequency and Body beside it, and one row of filter, reverb and echo controls below](docs/images/drosera.png) A feedback drone synth, as a VST3 plugin for macOS 11 or newer, on Apple silicon and Intel. Drosera makes sound on its own: a noise floor feeds two string resonators inside a saturating loop, and you steer -what the loop settles on. Play it with its knobs, excite it with audio, or put it on a note with MIDI. It comes with -15 presets. +what the loop settles on. Play it with its knobs, excite it with audio, or put it on a note with MIDI, up to three +notes at once. It comes with 16 presets. ## Install @@ -24,6 +24,12 @@ xattr -dr com.apple.quarantine ~/Library/Audio/Plug-Ins/VST3/Drosera.vst3 ## Using it +Everything is on one page. In the middle, a sundew shows the sound's real spectrum: each tentacle is a frequency band, +from the lowest at the top, clockwise, and it unfurls as its band sounds. Feedback sits at its heart, with Frequency and +Body beside it. Below, one row holds the filters, the reverb and the echo, with its PONG controls. Red controls are +inside the feedback loop and change what the drone sustains; amber ones come after it and colour what comes out. +Strings, Output and MIDI open from the SETUP field. Drag the window's corner to scale it from 75 % to 150 %. + See the [manual](docs/MANUAL.md) for every control, MIDI, and the known limits. ## Status diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 1f133cd..592c4ba 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -9,6 +9,8 @@ Read this before distributing a binary. Nothing here is legal advice. | `ReverbSc`, `Tone` from that DaisySP | `third_party/DaisySP/Source/Effects/reverbsc.*`, `Filters/tone.*` | **Treat as LGPL-2.1** | See below. | | JUCE 8.0.9 | fetched by CMake | **AGPLv3 or commercial** | A binary built on it must comply with one of the two. | | VST3 SDK | inside JUCE | Steinberg proprietary or GPLv3 | See JUCE's `LICENSE.md`. | +| Inter 4.1 (The Inter Project Authors) | `assets/fonts/Inter-*.ttf`, embedded in the plugin | SIL Open Font License 1.1, see `assets/fonts/OFL-Inter.txt` | The interface's text. Unmodified. | +| Cormorant Garamond 4.002 (the Cormorant Project Authors) | `assets/fonts/CormorantGaramond-Medium.otf`, embedded in the plugin | SIL Open Font License 1.1, see `assets/fonts/OFL-Cormorant.txt` | The name in the header. Unmodified. | ## ReverbSc and Tone diff --git a/assets/fonts/CormorantGaramond-Medium.otf b/assets/fonts/CormorantGaramond-Medium.otf new file mode 100644 index 0000000..cd7e600 Binary files /dev/null and b/assets/fonts/CormorantGaramond-Medium.otf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/OFL-Cormorant.txt b/assets/fonts/OFL-Cormorant.txt new file mode 100644 index 0000000..507d70f --- /dev/null +++ b/assets/fonts/OFL-Cormorant.txt @@ -0,0 +1,93 @@ +Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-Inter.txt b/assets/fonts/OFL-Inter.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/OFL-Inter.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 4bb5d90..1e3a80f 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -43,8 +43,9 @@ Checks how MIDI notes are assigned to the three voices, with no JUCE. build/ui_test_artefacts/Release/ui_test ``` -Covers the interface: the spectrum path to the plant, the readouts, tabs, and what the editor shows while MIDI and WARP -change what sounds. It opens no window on screen. +Covers the interface: the spectrum path to the plant, the plant's shape and how it follows the sound, the filter and +Tone arcs, the readouts, the setup panel, the window's scale, and what the editor shows while MIDI, POLY and WARP change +what sounds. It opens no window on screen. ```bash build/plugin_host_test_artefacts/Release/plugin_host_test build/Drosera_artefacts/Release/VST3/Drosera.vst3 tests/fixtures/state-baseline.bin @@ -66,6 +67,7 @@ proves the engine still renders exactly the same. ## Other tools - `build/pitch_probe [body] [first] [last] [step]` prints the pitch measured for a range of MIDI notes. -- `editor_snapshot [paramId=value | program=N | note=N | tab=N | sound=1 ...]` draws the window to an image. - `sound=1` renders a second of audio first, so the plant shows real sound. +- `editor_snapshot [paramId=value | program=N | note=N | sound=1 | setup=1 | scale=N ...]` draws the window + to an image. `sound=1` renders a second of audio first, so the plant shows real sound. `setup=1` opens the setup panel, + and `scale=N` draws the window at N times 900×560. - `tools/hardware-comparison/` compares the plugin against a recording of a real unit. See its `PROTOCOL.md`. diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 38de4f0..ee4ed02 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -6,21 +6,35 @@ DSP is the Audrey II firmware's own code at a fixed 48 kHz. Readouts show what the engine really uses, not the knob's position. These curves are far from linear. +## The window + +One page, with no tabs. In the middle band, Frequency, the plant with Feedback at its heart, and Body. Below, one row of +controls in four groups, each named under its knobs: FILTER and REVERB on the left, PONG in the centre, ECHO on the right. +The header holds the preset selector on the left and, on the right, the SETUP field that opens Strings, Output and MIDI. + +Colour says where a control sits. **Red** controls are inside the feedback loop, so they change what the drone sustains. +**Amber** controls are after the loop, so they colour what comes out. + +Drag the window's corner to scale it from 75 % to 150 %. The scale is saved with your project. + ## The plant The sundew in the middle of the window shows the output's real spectrum. Each of its 54 tentacles is a frequency band, -from 30 Hz at the top, clockwise to 16 kHz, and its length and drop of dew follow that band's level from −72 dBFS to 0 -dBFS. Nothing moves unless the sound does: a silent plugin shows a still plant. +from 30 Hz at the top, clockwise to 16 kHz, so a change of pitch moves the light around the plant. A band's +level, from −54 dBFS to 0 dBFS, unfurls its tentacle and swells its drop of dew: at rest the tentacles are short and +curled. Nothing moves unless the sound does: a silent plugin shows a still, closed plant. Low bands are coarser than high ones. Below about 150 Hz several tentacles share what one FFT bin measured. +With POLY on, the drops of the bands that hold the strings' notes get a fine ring, and Frequency shows the three notes. + Readouts can carry a tag. **MIDI** on Frequency means a note sets the pitch, and **SNAP** on Body means Body shows the time -snapped to that note. Those knobs look dimmed, because they are not what sounds, but they keep working. +snapped to that note. **LIMIT** on an interval means the voice cannot reach it, and the readout shows the interval it plays. +Those knobs look dimmed, because they are not what sounds, but they keep working. ## Main controls -Feedback sits at the plant's heart, with Frequency and Body on either side. Everything else is in the tabs below, and the -open tab is saved with your project. +Feedback sits at the plant's heart, with Frequency and Body on either side. | Control | Range | What it does | |---|---|---| @@ -31,23 +45,25 @@ open tab is saved with your project. The drone settles where the resonances of the string and of the Body delay coincide, which is rarely the exact pitch Frequency shows. Moving Body can jump the drone to another partial. -## Filters tab +## FILTER | Control | Range | What it does | |---|---|---| | Lowpass | 100 Hz to 18 kHz | Lowpass inside the loop | | Highpass | 10 Hz to 4 kHz | Highpass inside the loop | -Both are in the feedback path, so they shape which partials sustain, not only the tone. +Both are in the feedback path, so they shape which partials sustain, not only the tone. The arc of ticks above the +two knobs draws the band that survives: each tick's length is the two filters' gain at its frequency, from 10 Hz on the +left to 18 kHz on the right. -## Reverb tab +## REVERB | Control | What it does | |---|---| | Reverb Mix | Dry to wet, inside the loop | | Reverb Decay | Reverb feedback, 20 % to 100 %, on a curved law | -## Echo tab +## ECHO | Control | What it does | |---|---| @@ -56,13 +72,14 @@ Both are in the feedback path, so they shape which partials sustain, not only th | Echo Feedback | 100 % is unity, and up to 150 % it runs away, bounded by a soft clip | | Echo Warp (WARP) | The firmware's delay switch. Echo Time's readout shows the shortened time | -Echo Send, Echo Time and Echo Feedback also drive Echo Pong, in the Pong tab. +Echo Send, Echo Time and Echo Feedback also drive Echo Pong. The switch beside them chooses CLASSIC, the firmware's echo, or +PONG. The dot beside Time blinks at the real time between repeats, and with PONG two dots take turns, left and right. **WARP** halves the Echo Time knob's position before its square-law curve. Above about a quarter of the knob's travel the echo gets 2.8 to 3.9 times shorter, not twice as short, so it acts as a doppler warp and not as a tempo halving. Lower down the difference shrinks, because Echo Time cannot go below 50 ms. -## Pong tab +## PONG | Control | What it does | |---|---| @@ -72,7 +89,8 @@ halving. Lower down the difference shrinks, because Echo Time cannot go below 50 | Echo Level | -30 to +6 dB on the echo's repeats. 0 dB by default | | Echo Pong (PONG) | Makes the firmware's echo bounce between left and right. Off by default | -The four knobs are dimmed while PONG is off, and stay usable. +The four knobs are dimmed in CLASSIC, and stay usable, so they can be set before switching. The arc above Tone draws where +the echo's band-pass sits. **PONG** sums the input to mono and starts on the left, and Echo Time is the time between bounces. The two lines of the echo feed each other, so the repeats keep the firmware's color, band-passed above the drone, until Drive and Tone move. @@ -81,7 +99,7 @@ bounce: with Drive at zero, Echo Feedback runs away above 100 %, and with Drive Echo Width and Echo Level act on what comes out, not on the loop, so they change neither how long the repeats last nor where Echo Feedback runs away. At 0 %, Width plays each repeat in the middle at half the level it had on its side. -## Output tab +## SETUP: Output | Control | What it does | |---|---| @@ -93,7 +111,7 @@ The output has a limiter and a final hard clip at full scale, because the loop c The audio input is optional, and excites the strings. It stays off until the host connects the plugin's input bus. Some presets need it to make sound. -## MIDI tab +## SETUP: MIDI Both switches are off by default, and Drosera then ignores notes. @@ -112,7 +130,7 @@ pitch back after release: move it again. **MIDI Gate (GATE)** closes the feedback unless a key is down, so the drone swells on note-on and dies away on note-off. -## STRINGS tab +## SETUP: Strings Three-string polyphony lets Drosera sound up to three notes at once, as three feedback loops. It is off by default, and with it off Drosera sounds exactly as before. diff --git a/docs/design/drosera-redesign-mockup.html b/docs/design/drosera-redesign-mockup.html new file mode 100644 index 0000000..55397f3 --- /dev/null +++ b/docs/design/drosera-redesign-mockup.html @@ -0,0 +1,393 @@ + + + + + +Drosera redesign + + + + + +
+
+
+ Scala + + + Trascina i knob in verticale · doppio clic per il default · il suono è simulato +
+
+ + + diff --git a/docs/images/drosera.png b/docs/images/drosera.png index 0808b4c..52377f3 100644 Binary files a/docs/images/drosera.png and b/docs/images/drosera.png differ diff --git a/docs/superpowers/plans/2026-11-03-interface-redesign.md b/docs/superpowers/plans/2026-11-03-interface-redesign.md new file mode 100644 index 0000000..11168b0 --- /dev/null +++ b/docs/superpowers/plans/2026-11-03-interface-redesign.md @@ -0,0 +1,223 @@ +# Interface Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the tabbed Rugiada window with the one-page, symmetric interface of the spec: plant at the centre, Frequency and Body beside it, one row of FILTER · REVERB | PONG | ECHO, a setup panel, and a window that scales from 75 % to 150 %. + +**Architecture:** The editor draws a 900×560 content component and scales it with a transform. New focused components replace `TabPanel`: `ControlRow`, `SetupPanel`, `HeaderBar`, and a rewritten `PlantView`. Logic with no JUCE (`ResponseArcs.h`, `EchoPulse.h`, `Readouts.h`, `SpectrumBands.h`) is tested alone. The audio thread and the engine do not change. + +**Tech Stack:** C++17, JUCE 8.0.9 (`juce_gui_basics`, `juce_dsp`), DaisySP's `fmap` for parameter mappings, CMake with Ninja, `juce_add_binary_data` for the fonts. + +**Spec:** `docs/superpowers/specs/2026-11-03-interface-redesign-design.md`. The visual reference is `docs/design/drosera-redesign-mockup.html`: every coordinate below is in its 900×560 frame. + +## Global Constraints + +- No allocation, locks or file access on the audio thread. `render_test` counts allocations (CLAUDE.md rule 1). +- With the plugin-only features off, renders stay byte-identical to `tools/hardware-comparison/reference/SHA256SUMS` (rule 2). Nothing here touches the engine. +- Parameter IDs and names never change, and stay in English (rule 5). +- Do not regenerate `tests/fixtures/state-baseline.bin`. `plugin_host_test` must pass unchanged. +- Colours, verbatim from the spec: background `#1e1416` to `#0f0a0b`; loop `#d0465c`, tentacle bases `#a33244`; echo `#ffc27a`; knob track `#2a1f21`; text `#eadacd`; secondary text `#9d8a83`; hairlines `#33272a` to `#3d2f31`. +- Fonts: Cormorant Garamond for the name, Inter for everything else, embedded as binary data, with JUCE's default sans-serif as fallback. +- There is no invented motion: a silent plugin shows a still, closed plant. +- Levels rise with a 40 ms attack and fall with a 300 ms release, as today. +- Comments and names follow the surrounding code: short comments that say why, English everywhere. + +## Review Focus + +1. **A host restoring state off the message thread while the window reads or writes the scale** must not crash or tear: `uiScale` is an atomic, read and written like `uiTab` today. Test in Task 7. +2. **A window resized by the host to a size outside 675×420 to 1350×840, or with another aspect**, must keep the content's proportions and stay usable. Test in Task 7. +3. **Changes made while the setup panel is closed** (a preset, an automation lane) must show when it opens, as hidden tabs did. Test in Task 4. +4. **Clicking a dimmed PONG knob in CLASSIC** must still change the parameter, so it can be set before switching. Test in Task 4. +5. **POLY with fewer than three sounding notes** must name only the voices that sound, and never print a note for a silent voice. Test in Task 3. + +--- + +### Task 1: Theme, fonts and knob drawing + +**Files:** +- Create: `assets/fonts/CormorantGaramond-Medium.ttf`, `assets/fonts/Inter-Regular.ttf`, `assets/fonts/Inter-Medium.ttf`, `assets/fonts/OFL.txt` +- Create: `src/plugin/ui/LabeledKnob.h`, `src/plugin/ui/LabeledKnob.cpp` (moved out of `TabPanel.*`) +- Modify: `src/plugin/ui/Theme.h`, `src/plugin/ui/DroseraLookAndFeel.h/.cpp`, `src/plugin/ui/TabPanel.h/.cpp`, `src/plugin/PluginEditor.cpp`, `src/plugin/ui/PlantView.cpp`, `CMakeLists.txt` +- Test: `tests/ui_test.cpp` + +**Interfaces:** +- Produces, in `namespace drosera::theme`: `loop()`, `loopDeep()`, `echo()`, `track()`, `text()`, `textDim()`, `hairline()` (`#33272a`), `cartouche()` (`#3d2f31`), `backgroundCentre()`, `backgroundEdge()`, all `juce::Colour`; `juce::Font titleFont()` (Cormorant Garamond 26, kerning for 9 px spacing), `labelFont()` (Inter Medium 11), `largeLabelFont()` (Inter Medium 14), `valueFont()` (Inter 10), `largeValueFont()` (Inter 12), `captionFont()` (Inter Medium 9.5, kerning for 3 px spacing); `bool fontsEmbedded()`. +- Produces, in `ui/LabeledKnob.h`: `enum class KnobSize { large, small, mini };` `enum class KnobGroup { loop, echo };` `LabeledKnob(juce::RangedAudioParameter&, const juce::String& caption, KnobSize, KnobGroup)`, keeping `slider()`, `parameter()`, `show(value, tag)`, `readout()`, `tag()`, and adding `void setDimmed(bool)` (alpha 0.35, still enabled) and `static int radius(KnobSize)` (48, 20, 18). +- Produces: `DroseraLookAndFeel::kGroup` slider property (`"loop"` or `"echo"`) next to `kMainControl`. + +- [ ] **Step 1: Ask the owner to approve downloading the three font files and the OFL licence from Google Fonts** (the `google/fonts` repository, `ofl/cormorantgaramond` and `ofl/inter`). Downloading needs their yes. If Inter only ships as a variable font, ask them which static weights to use, or instance Regular and Medium from it. +- [ ] **Step 2: Write the failing test `fontsAreEmbedded`** in a new `themeTests()` called from `main`: `check(theme::fontsEmbedded(), "fontsAreEmbedded")`, and `check(theme::titleFont().getTypefaceName() == "Cormorant Garamond", "titleUsesCormorant")`. +- [ ] **Step 3: Run it to see it fail.** `cmake --build build --target ui_test && build/ui_test_artefacts/Release/ui_test`. Expected: does not compile, `fontsEmbedded` undeclared. +- [ ] **Step 4: Add `juce_add_binary_data(DroseraFonts SOURCES assets/fonts/*.ttf)`** and link it to `Drosera`, `ui_test` and `editor_snapshot`. Implement the theme with typefaces created once from `BinaryData` (`juce::Typeface::createSystemTypefaceFor`), falling back to `juce::FontOptions` defaults when a typeface is null. Replace the old colour names (`background`, `panel`, `stalk`, `dew`, `knob`, `heart`) at every use. +- [ ] **Step 5: Move `LabeledKnob` to its own files with the new constructor**, and update `TabPanel` and the editor to pass `KnobSize` and `KnobGroup` (Frequency and Body: `large, loop`; tab knobs: `small`, `loop` for filters and reverb, `echo` for the rest). Captions below the knob in `labelFont`/`largeLabelFont`, values below that in `valueFont`/`largeValueFont`, as in the mockup's `drawKnob`. +- [ ] **Step 6: Redraw the rotary slider** in `DroseraLookAndFeel::drawRotarySlider`: a 270° track in `track()`, the value arc in the group's colour (`loop()` or `echo()`), a round cap with a radial gradient `#2f2426` to `#151012`, a pointer line in `text()`. Large knobs get a 21-tick scale outside the arc, every fifth tick longer, ticks below the value in the group colour. Stroke widths: 4 large, 2.6 small and mini. Use a soft second stroke at low alpha for the glow, not a blur. +- [ ] **Step 7: Run `ui_test`.** Expected: every check OK, the two new ones included. +- [ ] **Step 8: Commit.** `git add assets CMakeLists.txt src/plugin tests/ui_test.cpp && git commit -m "Embed the interface fonts and draw knobs in their group's colour"` + +### Task 2: Response arcs and the echo pulse + +**Files:** +- Create: `src/plugin/ui/ResponseArcs.h`, `src/plugin/ui/EchoPulse.h` (no JUCE) +- Test: `tests/ui_test.cpp` + +**Interfaces:** +- Produces, in `namespace drosera::arcs`: `constexpr int kBandTicks = 26, kToneTicks = 11;` `constexpr float kBandStub = 3.0f, kBandSpan = 14.0f, kToneStub = 2.0f, kToneSpan = 9.0f;` `float filterGain(float hz, float highpassHz, float lowpassHz)` as `1/sqrt(1+(fc/f)^4) * 1/sqrt(1+(f/fc)^4)` (the loop's two 12 dB/octave filters); `float bandTickHz(int i)` log-spaced 10 Hz to 18 kHz; `void bandArc(float highpassHz, float lowpassHz, float* lengths)`, each `kBandStub + kBandSpan * gain`; `float toneTickHz(int i)` log-spaced 100 Hz to 6.4 kHz; `void toneArc(float centreHz, float* lengths)`, each `kToneStub + kToneSpan * exp(-0.9 * d * d)` with `d = log2(f / centre)`. +- Produces, in `namespace drosera::pulse`: `constexpr double kFlashSeconds = 0.08;` `enum class Lit { none, single, left, right };` `Lit echoPulse(double seconds, double periodSeconds, bool pong, bool sending)`: `none` when not sending; otherwise lit only for the first `kFlashSeconds` of each period (period floored at 0.05 s), `single` in CLASSIC, and in PONG `left` on even repeats and `right` on odd ones. + +- [ ] **Step 1: Write the failing tests** in a new `arcTests()`: + - `bandArcFollowsFilters`: with `bandArc(1000, 18000, len)`, every tick with `bandTickHz(i) < 300` has `len[i] < kBandStub + 0.1f * kBandSpan`, the tick nearest 5 kHz has `len > kBandStub + 0.9f * kBandSpan`. + - `bandArcNeverVanishes`: with `bandArc(4000, 100, len)`, every `len[i] >= kBandStub`. + - `toneArcPeaksAtTone`: with `toneArc(800, len)`, the longest tick's frequency is within one tick step of 800 Hz, and `toneArc(3200, len)` peaks above 2 kHz. + - `pulseFollowsEchoTime`: `echoPulse(0.01, 0.5, false, true) == Lit::single`, `echoPulse(0.2, 0.5, false, true) == Lit::none`, `echoPulse(0.51, 0.5, true, true) == Lit::right`, `echoPulse(1.01, 0.5, true, true) == Lit::left`, `echoPulse(0.01, 0.5, false, false) == Lit::none`. +- [ ] **Step 2: Run them to see them fail** (headers missing). +- [ ] **Step 3: Implement both headers** from the Interfaces block. +- [ ] **Step 4: Run `ui_test`.** Expected: all OK. +- [ ] **Step 5: Commit.** `git commit -m "Add the filter and Tone arcs and the echo pulse, with no JUCE"` + +### Task 3: Readouts that say what sounds + +**Files:** +- Modify: `src/plugin/ui/Readouts.h`, `src/plugin/ui/SpectrumBands.h`, `src/plugin/EditorBridge.h`, `src/plugin/PluginProcessor.cpp`, `src/plugin/PluginEditor.cpp` +- Test: `tests/ui_test.cpp` + +**Interfaces:** +- Produces, in `drosera::readouts`: `Text playedInterval(bool polyRunning, float rootNote, float voiceNote, int knobSemitones, const std::string& knobText)` returns `{knobText, ""}` unless POLY runs, the voice has a note (`>= 0`) and `lround(voiceNote - rootNote) != knobSemitones`; then `{format("%+.0f st", actual), "LIMIT"}`. `std::string chord(const float* notes, int count)` joins the `noteName` of each note `>= 0` with `" \xc2\xb7 "` (a middle dot). +- Produces, in `SpectrumBands.h`: `int bandOfNote(float midiNote)`, the band whose centre is nearest the note's frequency, or `-1` below 30 Hz or above 16 kHz. +- Produces, in `EditorBridge`: `virtual float publishedNote(int voice) const = 0;` (the processor returns `core->PublishedNote(voice)`) and `virtual bool polyRunning() const = 0;` (POLY's parameter is on). +- Consumes: the existing `interval()`, which keeps showing the note name under polyphonic MIDI. + +- [ ] **Step 1: Write the failing tests.** In `readoutTests()`: + - `is(playedInterval(true, 72, 84, 24, "+24 st"), "+12 st", "LIMIT")` + - `is(playedInterval(true, 60, 67, 7, "+7 st"), "+7 st", "")` + - `is(playedInterval(false, 72, 84, 24, "+24 st"), "+24 st", "")` + - `is(playedInterval(true, 72, -1, 24, "+24 st"), "+24 st", "")` + - `chord(notes{48, 55, 60}, 3) == "C3 · G3 · C4"` and `chord(notes{48, -1, 60}, 3) == "C3 · C4"` + - `bandOfNote(69) == ` the band nearest 440 Hz by `bandCentreHz`, `bandOfNote(16) == -1`. + + In `editorTests()`, `intervalShowsWhatSounds`: Frequency at 1.0 (note 72), `poly` on, `interval2` at 1.0 (+24), render 0.2 s, `refreshDisplay`; then `readout("interval2") == "+12 st"` and `tag("interval2") == "LIMIT"`. And `frequencyNamesTheChord`: with `poly` on and Frequency, `interval2`, `interval3` at their defaults, after 0.2 s `readout("freq")` contains `" · "`. +- [ ] **Step 2: Run them to see them fail.** +- [ ] **Step 3: Implement the three helpers and the two bridge methods.** In `refreshDisplay`, the interval knobs use `interval()` under polyphonic MIDI and `playedInterval()` otherwise, with `publishedNote(0)` as the root. With POLY running, Frequency shows `chord()` of the three published notes, tagged MIDI only under MIDI pitch. +- [ ] **Step 4: Run `ui_test`.** Expected: all OK, the existing interval tests included. +- [ ] **Step 5: Commit.** `git commit -m "Show the interval each voice really plays, and the chord under POLY"` + +### Task 4: One page: the control row and the setup panel replace the tabs + +**Files:** +- Create: `src/plugin/ui/ControlRow.h/.cpp`, `src/plugin/ui/SetupPanel.h/.cpp`, `src/plugin/ui/Pill.h` (a pill-shaped toggle and the CLASSIC/PONG switch) +- Delete: `src/plugin/ui/TabPanel.h/.cpp` +- Modify: `src/plugin/PluginEditor.h/.cpp`, `src/plugin/EditorBridge.h`, `src/plugin/PluginProcessor.cpp` (drop `uiTab`), `src/plugin/ui/Readouts.h` (drop `echoShaping`), `CMakeLists.txt`, `tests/editor_snapshot.cpp` (drop `tab=`) +- Test: `tests/ui_test.cpp` + +**Interfaces:** +- Consumes: `LabeledKnob` (Task 1), `arcs::*` and `pulse::*` (Task 2). +- Produces `ControlRow(juce::AudioProcessorValueTreeState&)`: `const std::map>& knobs() const`; `juce::Button* toggle(const juce::String& paramId)` for `echopong` and `echohalve`; `void refresh(float highpassHz, float lowpassHz, float toneHz, bool pong, pulse::Lit)`, which redraws the arcs, dims PONG's knobs in CLASSIC with `setDimmed`, and lights the pulse. +- Produces `SetupPanel(juce::AudioProcessorValueTreeState&)`: `knobs()`, `toggle(paramId)` for `poly`, `polylock`, `midipitch`, `midigate`; `void refresh(bool polyOn)`, which dims Coupling and the intervals with POLY off. +- Produces on `DroseraEditor`: `void setSetupOpen(bool)`, `bool setupOpen() const`, `juce::Button* toggle(const juce::String& paramId)`; `slider(id)` searches the main knobs, the row and the panel. `setTab`, `tab`, `tabBounds`, `toggleBounds` are removed. +- Produces on `EditorBridge`: `openTab` and `setOpenTab` are removed, and the processor no longer saves `uiTab`. + +Layout, in content coordinates, from the mockup: + +| Item | Where | +|---|---| +| Frequency, Body (large) | centres (150, 236) and (750, 236) | +| Row knobs, centre y 466 | Highpass 66, Lowpass 126, Mix 206, Decay 266 (small); Drive 360, Tone 420, Width 480, Level 540 (mini); Send 634, Time 694, Feedback 754 (small) | +| CLASSIC/PONG switch | centre (834, 458), 92×22 | +| WARP pill | centre (834, 490), 50×18 | +| Band arc | ticks from x 46 to 146, base y 430 | +| Tone arc | ticks from x 404 to 436, base y 432 | +| Echo pulse | dots of radius 2.5 at (720, 452) in CLASSIC, (716, 452) and (726, 452) in PONG | +| Cartouches, y 534 | FILTER 44–148, REVERB 184–288, PONG 340–560 (alpha 0.4 in CLASSIC), ECHO 612–866 | +| Setup panel | from y 368 to the bottom, over a scrim on the rest. Cartouches at y 392: STRINGS 100–320, OUTPUT 375–525, MIDI 660–830. POLY (175, 418), LOCK (245, 418), Coupling (140, 476), Interval 2 (210, 476), Interval 3 (280, 476), Volume (415, 476), Input (485, 476), PITCH (705, 456), GATE (785, 456) | + +A cartouche is a hairline pill in `cartouche()` around the name in `captionFont`, with fine rules in `hairline()` out to the group's edges, 6 px from the pill. + +- [ ] **Step 1: Replace the tab tests.** Remove `tabPersists`, `tabSurvivesBadState`, `tabSurvivesMissingState`, `tabsShowTheirControls`, `stringsTabShowsItsControls`, `tabsFitTheWindow`, `pongTabSitsAfterEcho`, `otherTabsKeepTheirPlaces`, `echoTabShowsItsControls`, `pongTabShowsItsControls`, `pongControlsFit`, `shapingTagWithPongOff`, `shapingTagWithPongOn`, `pongTabPersists`, and the `echoShaping` readout checks. Add: + - `windowSize`: `getWidth() == 900 && getHeight() == 560`. + - `everyControlHasAWidget`: each of the 25 IDs (`freq body fbgain hpf lpf revmix revdecay echodrive echotone echowidth echolevel echosend echotime echofb echopong echohalve poly polylock coupling interval2 interval3 volume inputgain midipitch midigate`) has `slider(id)` or `toggle(id)`. + - `playControlsAlwaysShow`: with the editor visible and the setup closed, Frequency, Body, Feedback and the eleven row knobs are `isShowing()`, and PONG's four are showing in both modes. + - `setupPanelOpensAndCloses`: `setSetupOpen(true)` shows `coupling`, `volume` and `midipitch`; `setSetupOpen(false)` hides them. + - `changesReachTheClosedPanel`: with the panel closed, set `coupling` to 0.7 and then `setCurrentProgram(10)`; on opening, the Coupling slider equals the parameter. + - `pongControlsDimInClassic`: `echopong` 0, `refreshDisplay(0)`: `echodrive`'s knob has alpha below 1 and is showing; setting its slider to 0.8 sets the parameter to 0.8. `echopong` 1: alpha 1. + - `stringsDimWithPolyOff`: with `poly` 0 and the panel open, `coupling`'s knob has alpha below 1. + - `rowControlsFit`: the row's knob cells, the switch and the WARP pill lie within x 0 to 900 and do not intersect. + - `savedTabIsIgnored`: a state with `uiTab="3"` loads with its parameters, and the editor opens at 900×560. +- [ ] **Step 2: Run `ui_test` to see the new tests fail.** +- [ ] **Step 3: Implement `Pill.h`, `ControlRow` and `SetupPanel`** with the layout above. The mode switch is a `juce::Button` attached to `echopong` with a `ButtonParameterAttachment`, drawn as the mockup's two-part pill, titled "Echo Pong" for screen readers. The pulse dots are painted by `ControlRow`, in `echo()`, lit or at alpha 0.2. +- [ ] **Step 4: Rebuild the editor at 900×560**: Frequency and Body as large knobs, the row, the panel and its scrim (a click on the scrim or Escape closes the panel). Compute the arcs' Hz with `daisysp::fmap(norm, 10, 4000, LOG)` for Highpass, `(norm, 100, 18000, LOG)` for Lowpass and `(norm, 200, 3200, LOG)` for Tone, as the engine does. Advance a clock on each tick for the pulse and pass `echoPulse(clock, bridge.echoSeconds(), pong, echoSend > 0)`. Leave the preset combo box and the plant where they are: Tasks 5 and 6 move them. Delete `TabPanel`, `uiTab`, `openTab`, `setOpenTab` and `echoShaping`. Drop `tab=` from `editor_snapshot`. +- [ ] **Step 5: Run `ui_test`, `render_test` and `plugin_host_test`.** Expected: all OK. +- [ ] **Step 6: Commit.** `git commit -m "Replace the tabs with one row of controls and a setup panel"` + +### Task 5: The header + +**Files:** +- Create: `src/plugin/ui/HeaderBar.h/.cpp` +- Modify: `src/plugin/PluginEditor.h/.cpp`, `src/plugin/ui/DroseraLookAndFeel.cpp` (popup menu colours), `CMakeLists.txt` +- Test: `tests/ui_test.cpp` + +**Interfaces:** +- Produces `HeaderBar(juce::AudioProcessor&)`: `std::function onProgramChosen; std::function onSetupClicked;` `void setProgram(int)`, `void setSetupOpen(bool)`; `juce::Button& previousButton()`, `nextButton()`, `presetButton()`, `setupButton()`; `juce::PopupMenu presetMenu() const`, one ticked item per program. +- Consumes: `DroseraEditor::selectProgram(int)` and `setSetupOpen(bool)` (Task 4). + +Layout: bounds (0, 0, 900, 70). PRESET field at (40, 30, 210, 30), radius 7, with ‹ and › cells 30 px wide divided by hairlines, the name centred and ▾ before the › cell. SETUP field at (650, 30, 210, 30) with "Strings · Output · MIDI" left-aligned and ▾, ▴ when open, in `echo()` when open. Captions "PRESET" and "SETUP" at y 20 in `captionFont`. "DROSERA" centred at y 46 in `titleFont`. + +- [ ] **Step 1: Write the failing tests.** `presetArrowsCycle`: from program 0, `previousButton().triggerClick()` (with the message loop run, or `onClick()` called) reaches the last program and `nextButton()` comes back to 0. `presetMenuListsEveryPreset`: `presetMenu()` has `getNumPrograms()` items and the current one is ticked. `setupFieldOpensThePanel`: `setupButton()` toggles `setupOpen()`. Keep `presetNotifiesHost`. +- [ ] **Step 2: Run them to see them fail.** +- [ ] **Step 3: Implement `HeaderBar`** and put it in the editor in place of the combo box and its arrows. The preset button shows `presetMenu()` with `showMenuAsync` and calls `onProgramChosen`. The editor keeps following the host's program changes in `refreshDisplay`. +- [ ] **Step 4: Run `ui_test`.** Expected: all OK. +- [ ] **Step 5: Commit.** `git commit -m "Add the header with the preset and setup fields"` + +### Task 6: The sundew + +**Files:** +- Modify: `src/plugin/ui/PlantView.h/.cpp`, `src/plugin/PluginEditor.cpp` +- Test: `tests/ui_test.cpp` + +**Interfaces:** +- Produces on `PlantView`: `static constexpr int tentacleBand(int tentacle) { return (tentacle * 23) % kNumBands; }`; `void setVoiceBands(const std::array&)` (`-1` for none) and `bool isMarked(int band) const`. Keeps `feedback()`, `setFeedbackText()`, `update()`, `level()`. +- Consumes: `bandOfNote` (Task 3) and `publishedNote` / `polyRunning` from the bridge, for the editor to call `setVoiceBands` on each tick. + +Geometry, in the component's coordinates; the editor gives it bounds (190, 66, 520, 340), so its centre is (450, 236) in the content: + +- Leaf: radius `50 * (1 + 0.05 sin(3a + 1.1) + 0.035 sin(5a + 0.4) + 0.02 sin(8a))`, filled with a radial gradient from `rgb(110 + 40·mean, 30, 48)` through `#4d1222` at 0.6 to transparent at the edge, no outline, nine faint veins. +- Tentacle `j`, angle `2πj/54 − π/2` plus up to ±0.045 rad of jitter. Per tentacle, from a fixed-seed generator so the plant is the same every time: base 62–96, reach 70–115, rest curl ±(0.45–0.95) rad, sway ±0.125 rad, base width 2.1–3.0, drop size factor 0.7–1.3. +- With `amount` from the existing `bandAmount(level)`: length `(base + reach·amount)·(1 − 0.22·|sin a|)`, curl `restCurl·(1 − 0.8·amount)`, drawn as ten segments bending with `curl·u²`, width from `baseWidth + 0.8·amount` to 0.7, colour from `#6e1a2a` to `#d64a62`. +- Drop: radius `(1.5 + 4.3·amount)·factor`, a radial gradient with a highlight, and a glow of two translucent rings rather than a blur. A marked band's drop gets a 1 px ring in `text()` at radius + 3. +- Feedback: the slider fills a 64×64 square at the centre. A ring of radius 32 in `loop()` with a glow that follows the mean amount, and the value text inside. + +- [ ] **Step 1: Write the failing tests.** `tentaclesCoverEveryBand`: `tentacleBand(0..53)` is a permutation of 0..53. `plantMarksVoiceBands`: `setVoiceBands({10, -1, 30})` gives `isMarked(10) && isMarked(30) && !isMarked(20)`. The existing plant tests stay as they are. +- [ ] **Step 2: Run them to see them fail.** +- [ ] **Step 3: Rewrite `PlantView::paint` and `resized`** with the geometry above, and place the plant in the editor. Keep the repaint threshold: the plant repaints only when a drawn amount moves by more than `kRepaintChange`. +- [ ] **Step 4: Run `ui_test`.** Expected: all OK, `plantRestsWithNoAudio` and `plantStillWithLongBlocks` included. +- [ ] **Step 5: Commit.** `git commit -m "Draw the plant as a sundew whose tentacles unfurl with the sound"` + +### Task 7: A window that scales + +**Files:** +- Modify: `src/plugin/PluginEditor.h/.cpp`, `src/plugin/EditorBridge.h`, `src/plugin/PluginProcessor.cpp` +- Test: `tests/ui_test.cpp` + +**Interfaces:** +- Produces on `EditorBridge`: `virtual float uiScale() const = 0; virtual void setUiScale(float) = 0;`. The processor holds `std::atomic uiScale { 1.0f }`, saves it as the state property `uiScale`, and on load stores the saved value if it lies in [0.75, 1.5], otherwise 1.0. +- Produces on `DroseraEditor`: `float scale() const`. + +- [ ] **Step 1: Write the failing tests.** + - `scalePersists`: `editor->setSize(1350, 840)`, save the state, load it in a second processor: its editor is 1350×840. + - `scaleSurvivesBadState` and `scaleSurvivesMissingState`: with `uiScale="9"`, and with the property removed, the editor opens at 900×560 and the parameters are intact. + - `windowKeepsItsProportions`: through the constrainer, a request for 1000×1000 gives a height of `width * 560 / 900` within a pixel, and a request for 200×100 gives 675×420. + - `scaleSafeOffThread`: a thread restoring the state 2000 times while the message thread resizes the editor between 675 and 1350 wide and calls `refreshDisplay`, with no crash. +- [ ] **Step 2: Run them to see them fail.** +- [ ] **Step 3: Implement scaling.** Put every child in a 900×560 content component. `setResizable(true, true)`, a fixed aspect of 900/560, size limits 675×420 to 1350×840, and in `resized()` a `juce::AffineTransform::scale(getWidth() / 900.0f)` on the content, then `bridge_.setUiScale(scale)`. Open at `900 * uiScale()` wide. Raise the timer to 60 Hz. +- [ ] **Step 4: Run `ui_test`, `render_test` and `plugin_host_test`.** Expected: all OK. +- [ ] **Step 5: Commit.** `git commit -m "Scale the window from 75 % to 150 % and remember the scale"` + +### Task 8: Snapshots, documents and the checks by hand + +**Files:** +- Modify: `tests/editor_snapshot.cpp`, `docs/MANUAL.md`, `CHANGELOG.md`, `CLAUDE.md`, `docs/superpowers/specs/2026-11-01-rugiada-interface-design.md` (a note that it is replaced) + +- [ ] **Step 1: Give `editor_snapshot` `setup=1` and `scale=N`**, which call `setSetupOpen(true)` and `setSize(900 * N, 560 * N)`, and update its usage line. +- [ ] **Step 2: Render and review the images** against the mockup: `build/editor_snapshot_artefacts/Release/editor_snapshot out/classic.png echopong=0 sound=1`, `out/pong.png echopong=1 sound=1`, `out/setup.png setup=1`, `out/small.png scale=0.75`, `out/large.png scale=1.5`, and `out/silent.png` with no sound. Expected: the layout of the mockup, the silent plant closed and still, nothing clipped at either scale. Fix and re-render until they match. +- [ ] **Step 3: Update the documents.** In `MANUAL.md`, replace "Filters tab" to "STRINGS tab" with sections named after the groups (FILTER, REVERB, ECHO with its PONG controls, SETUP with Strings, Output and MIDI) and a short "The window" section on the layout, the colours' meaning, the arcs, the pulse and the scale; keep every control's table. Add an `[Unreleased]` entry to `CHANGELOG.md`. In `CLAUDE.md`'s code map, replace "tabs" with the new components and name `ResponseArcs.h` and `EchoPulse.h` among the files with no JUCE. Add a line at the top of the Rugiada spec saying this design replaces it. +- [ ] **Step 4: Run the whole suite** from `CLAUDE.md`'s commands: build, `voice_allocator_test`, `render_test`, `ui_test`, `plugin_host_test` with the baseline fixture, and `tools/hardware-comparison/check_reference.sh build/render_cli`. Expected: all pass, checksums unchanged. +- [ ] **Step 5: List for the owner the checks that need them**: the mouse and the preset menu in a DAW, as AU and VST3; GUI CPU below 5 % of one core with the window open at 60 Hz, falling back to 30 Hz above that; and the plant judged by eye with real sound. +- [ ] **Step 6: Commit.** `git commit -m "Document the new window and snapshot it in each state"` diff --git a/docs/superpowers/specs/2026-11-01-rugiada-interface-design.md b/docs/superpowers/specs/2026-11-01-rugiada-interface-design.md index 421e27a..3e238b6 100644 --- a/docs/superpowers/specs/2026-11-01-rugiada-interface-design.md +++ b/docs/superpowers/specs/2026-11-01-rugiada-interface-design.md @@ -1,5 +1,7 @@ # Rugiada interface design +> **Replaced** by `2026-11-03-interface-redesign-design.md`, which removes the tabs. + Sub-project 2 of 2. It builds on `2026-11-01-repository-and-release-design.md`, which creates `src/plugin/` and the CI that covers this work. diff --git a/docs/superpowers/specs/2026-11-03-interface-redesign-design.md b/docs/superpowers/specs/2026-11-03-interface-redesign-design.md new file mode 100644 index 0000000..05fba99 --- /dev/null +++ b/docs/superpowers/specs/2026-11-03-interface-redesign-design.md @@ -0,0 +1,187 @@ +# Interface redesign + +Replaces the Rugiada interface (`2026-11-01-rugiada-interface-design.md`). It builds on the bouncing echo of +`2026-11-03-echo-pong-revision-design.md`. + +## Goal + +The tabs hide too much: while the drone plays, most of the instrument is out of sight. The plant is decorative more +than informative, and each new feature has added a tab. Drosera is played live first and shaped by sound design +second, so the new window puts everything that is touched while playing on one page, keeps the plant at its centre, and +leaves room for what comes next. + +Success: the core, the filters and the effects can be played without changing page, and what the sound is doing can be +read at a glance. + +## Direction + +One page, no tabs, symmetric about the plant. No boxes and no signal lines: groups are separated by space, named by +light cartouches, and coloured by where they sit. **Drosera red** marks what is inside the feedback loop, which changes +what the drone sustains. **Amber** marks the echo, which colours what comes out. + +The approved mockup is `docs/design/drosera-redesign-mockup.html`. Its sound is simulated from the knob values: this +document decides where the two differ. + +## Layout + +The window is 900×560 at 100 %, drawn as vectors. It scales from 75 % to 150 % with its proportions locked. The scale is +saved in the plugin state. + +- **Header.** "DROSERA" centred, in Cormorant Garamond. On the left a **PRESET** field: previous arrow, the preset's + name with a menu of all presets, next arrow. On the right a twin **SETUP** field, "Strings · Output · MIDI", which + opens the setup panel. Each field has its caption above it. +- **Middle band**, on one line: **Frequency** large on the left, the **plant** in the centre with **Feedback** at its + heart, **Body** large on the right. The three share a vertical centre, and nothing in this band ever moves position. +- **Bottom row**, one row in three blocks of four places: FILTER and REVERB on the left, PONG in the centre, ECHO on the + right. Each group is named by a cartouche: + +| Group | Colour | Controls | +|---|---|---| +| FILTER | red | Highpass, Lowpass, with the band arc above them | +| REVERB | red | Mix, Decay | +| PONG | amber | Drive, Tone (with its band-pass arc), Width, Level | +| ECHO | amber | Send, Time (with the echo pulse), Feedback, and the CLASSIC / PONG switch with WARP below it | + +- **PONG's four controls are always visible.** With CLASSIC they are dimmed, because they have no effect there, and stay + usable so they can be set before switching. Nothing appears or disappears when the mode changes. +- **Setup panel.** Rises over the bottom of the window and dims the rest. It holds STRINGS (POLY, LOCK, Coupling, + Interval 2, Interval 3; the knobs are dimmed while POLY is off), OUTPUT (Volume, Input) and MIDI (PITCH, GATE). It + closes from the SETUP field, by clicking above it, or with Escape. + +All 25 controls of today's interface are on this page or in the setup panel. Parameter IDs and names do not change. + +Colours: + +| Use | Colour | +|---|---| +| Background, centre to edge | `#1e1416` to `#0f0a0b` | +| Loop controls and tentacles | `#d0465c`, tentacle bases `#a33244` | +| Echo controls, drops and readouts in use | `#ffc27a` | +| Knob track | `#2a1f21` | +| Text | `#eadacd` | +| Secondary text | `#9d8a83` | +| Hairlines and cartouches | `#33272a` to `#3d2f31` | + +Fonts: Cormorant Garamond for the name, Inter for everything else. Both are under the SIL Open Font License and are +embedded in the plugin as binary data. + +## The plant + +A sundew leaf seen from above: a round, slightly irregular blade with faint veins and no outline, fading into the +background. 54 tentacles grow from its rim, one per band, log-spaced from 30 Hz to 16 kHz as today, in order: the lowest +band at the top, then clockwise. A change of pitch moves the light around the plant. (An earlier version spread +neighbouring bands around the leaf; it hid every change of pitch, so it was dropped.) + +Each tentacle tapers from its base to its tip and ends in a drop of dew. Levels are shown from −54 dBFS to 0 dBFS. +(Rugiada's −72 dBFS kept the loop's noise and far harmonics showing, so a third of the tentacles stood half open under a +single note; from −54 dBFS the bands well below a played note rest.) A band's level drives its tentacle as a real sundew +moves: + +- **At rest** the tentacle is short and curled, and its drop is small and dim. A sounding tentacle stands on average at + least five times as far from its root as a silent one, so what sounds stands out from the plant's shape. +- **With sound** it lengthens and unfurls, and its drop swells and glows. +- The plant is wider than tall: tentacles reach about 22 % less upward and downward than sideways, so their tips stay + clear of the header and the bottom row. + +Levels rise with a 40 ms attack and fall with a 300 ms release. The leaf's colour and the glow of Feedback's ring follow +the mean of the bands. Feedback is drawn on the leaf as a ring with its value inside. + +**There is no invented motion.** A silent plugin shows a still plant, closed and at rest. + +**Three strings.** With POLY on, the drop of each tentacle whose band holds a sounding voice's note gets a fine ring, and +Frequency's readout shows the three notes, such as "C3 · G3 · C4". The spectrum is the mixed output, so the plant does +not pretend to separate the strings beyond this. + +## Arcs + +Two small arcs of ticks draw a filter's real response, and move only when their knobs turn: + +- **Band arc**, above Highpass and Lowpass, from 10 Hz to 18 kHz. Each tick's length follows the gain of both filters + at its frequency, with their slopes, so the arc draws the band that survives in the loop. Ticks in a cut band shrink + to a short stub and never vanish, so the whole scale stays readable. +- **Tone arc**, above Tone, the same way around the echo's band-pass centre. + +The arcs do not show the spectrum: the plant does. + +## Echo pulse + +Beside Time, a small amber dot blinks at the real time between repeats, from `echoSeconds()`, so WARP's shortening shows +too. With PONG it becomes two dots blinking in turn, left and right. With Echo Send at zero the pulse rests, because +no repeats sound. + +## Honest readouts + +Knobs show what sounds, as today: + +| Situation | Display | +|---|---| +| MIDI pitch in control | Frequency shows the note and Hz, tagged MIDI, and the knob is dimmed | +| MIDI pitch in control | Body shows the real snapped time in ms, tagged SNAP, and the knob is dimmed | +| WARP on | Time shows the real, shortened time | +| An interval beyond the note range | Interval shows the interval the voice really plays, so +24 that sounds as +12 reads "+12 st" | +| POLY on | Frequency shows the three sounding notes | + +## Components + +`src/plugin/ui/`, each with one job: + +| File | Job | +|---|---| +| `Theme.h` | Colours and the embedded fonts | +| `DroseraLookAndFeel.h/.cpp` | Large knobs with a scale, small knobs, pills, fields and menus, in the group's colour | +| `PlantView.h/.cpp` | Rewritten: leaf, tentacles, drops, Feedback's ring and the three strings' rings. Still runs the FFT and holds Feedback | +| `ResponseArcs.h` | New, no JUCE: tick lengths of the band arc and the Tone arc, from the parameters | +| `ControlRow.h/.cpp` | New, replaces `TabPanel`: the 4 \| 4 \| 4 row, cartouches, arcs, mode switch, WARP and the echo pulse | +| `HeaderBar.h/.cpp` | New: the PRESET and SETUP fields and the name | +| `SetupPanel.h/.cpp` | New: Strings, Output and MIDI | +| `Readouts.h` | Gains the real interval and the three notes | +| `SpectrumBands.h` | Unchanged | + +`src/plugin/PluginEditor.*` assembles these on a 900×560 content component, scales it with a transform, and runs the +timer at 60 Hz. `TabPanel` is deleted. + +`EditorBridge` loses `openTab` and `setOpenTab` and gains `uiScale` and `setUiScale`. The processor saves `uiScale` in its +state in place of `uiTab`, as an atomic, because hosts may restore state off the message thread. A saved `uiTab` is +ignored. The audio thread and the engine do not change. + +## Failure and edge cases + +- **A missing or out-of-range scale**, from an older project or a damaged one: the window opens at 100 %. +- **A scale the screen cannot fit:** the host's window limits apply, and the content keeps its proportions. +- **Fonts that fail to load:** JUCE's default sans-serif is used, and the layout stays the same. +- **POLY with fewer than three notes:** only the sounding voices get a ring and a name in the readout. +- **Sample rate changes, bands above Nyquist, non-finite samples, the editor closed while audio runs, several + instances:** as in the Rugiada design, since the tap and the bands do not change. + +## Testing + +Automated, in `tests/ui_test.cpp`, run by CI: + +- The band arc follows the filters: with Highpass at 1 kHz the ticks below 300 Hz are short, and no tick is ever below + the stub. The Tone arc peaks at Tone's centre. +- Silence puts every band at rest, and a sine lights the expected tentacle and its neighbours only. These tests stay. +- Each readout in the table above produces the expected text. +- The scale survives closing and reopening the editor. A missing, damaged or out-of-range scale opens at 100 %. A host + may restore state off the message thread while the window reads the scale. +- Every one of the 25 controls has its knob or switch, and PONG's four are visible in both modes. +- The tab tests are removed, since there are no tabs. + +Unchanged, and they must still pass: `render_test`, including its allocation count, `voice_allocator_test`, +`plugin_host_test` with `tests/fixtures/state-baseline.bin` (it checks only parameter values), and the reference checksums. + +`editor_snapshot` drops `tab=N` and gains `setup=1` and `scale=N`. Its images in CLASSIC, in PONG, with the setup panel +open, and at 75 % and 150 % are reviewed against the mockup. + +By hand, because they cannot be automated here: + +- The mouse and the preset menu in a DAW, as AU and VST3. +- GUI CPU use below 5 % of one core with the window open at 60 Hz, on the owner's Mac. Above that, the timer goes back + to 30 Hz. +- Whether the plant looks right with real sound. Lengths, curls and timings may need tuning by eye. + +## Out of scope + +- Any change to the sound or the engine. +- Themes, a light mode, and translated labels. +- User presets. +- Touch input. diff --git a/src/plugin/EditorBridge.h b/src/plugin/EditorBridge.h index d08c7b6..6b9ede8 100644 --- a/src/plugin/EditorBridge.h +++ b/src/plugin/EditorBridge.h @@ -11,10 +11,12 @@ class EditorBridge { virtual float soundingNote() const = 0; virtual bool polyMidiActive() const = 0; // polyphony on and MIDI Pitch in control virtual float voiceNote(int voice) const = 0; // negative when the voice has no note + virtual float publishedNote(int voice) const = 0; // the note the voice's loop is tuned to, MIDI or knobs + virtual bool polyRunning() const = 0; // POLY is on virtual float bodySeconds() const = 0; virtual float echoSeconds() const = 0; virtual double hostSampleRate() const = 0; // 0 until prepared virtual drosera::SpectrumTap& spectrumTap() = 0; - virtual int openTab() const = 0; // as saved, so not necessarily a valid tab - virtual void setOpenTab(int) = 0; + virtual float uiScale() const = 0; // the window's scale, 0.75 to 1.5, as saved + virtual void setUiScale(float) = 0; }; diff --git a/src/plugin/PluginEditor.cpp b/src/plugin/PluginEditor.cpp index a402418..ddd13cc 100644 --- a/src/plugin/PluginEditor.cpp +++ b/src/plugin/PluginEditor.cpp @@ -1,42 +1,52 @@ #include "PluginEditor.h" +#include #include "ui/Readouts.h" #include "ui/Theme.h" namespace { -constexpr int kWidth = 760, kHeight = 500, kTabsTop = 306, kPlantHeight = 314; +constexpr int kWidth = 900, kHeight = 560; +// The middle band's centre, shared by Frequency, the plant and Body. +constexpr int kBandY = 236; + +/// Hz of a 0..1 parameter on the engine's log curve, so the arcs draw what the filters do. +float hz(juce::AudioProcessorValueTreeState& apvts, const char* id, float min, float max) { + return daisysp::fmap(apvts.getRawParameterValue(id)->load(), min, max, daisysp::Mapping::LOG); +} } // namespace DroseraEditor::DroseraEditor(juce::AudioProcessor& p, juce::AudioProcessorValueTreeState& apvts, EditorBridge& bridge) : AudioProcessorEditor(p), apvts_(apvts), bridge_(bridge), feedbackAttachment_(*apvts.getParameter("fbgain"), plant_.feedback(), nullptr), - frequency_(*apvts.getParameter("freq"), "Frequency", true), - body_(*apvts.getParameter("body"), "Body", true), - tabs_(apvts) { + frequency_(*apvts.getParameter("freq"), "Frequency", drosera::KnobSize::large, drosera::KnobGroup::loop), + body_(*apvts.getParameter("body"), "Body", drosera::KnobSize::large, drosera::KnobGroup::loop), + row_(apvts), setup_(apvts), header_(p) { setLookAndFeel(&lookAndFeel_); - addAndMakeVisible(plant_); - addAndMakeVisible(frequency_); - addAndMakeVisible(body_); - addAndMakeVisible(tabs_); - - tabs_.onTabClicked = [this](int index) { setTab(index); }; - tabs_.setTab(bridge_.openTab()); - - for (int i = 0; i < p.getNumPrograms(); ++i) presetBox_.addItem(p.getProgramName(i), i + 1); - presetBox_.setSelectedId(p.getCurrentProgram() + 1, juce::dontSendNotification); - presetBox_.onChange = [this] { selectProgram(presetBox_.getSelectedId() - 1); }; - addAndMakeVisible(presetBox_); - previous_.onClick = [this] { selectProgram((processor.getCurrentProgram() + processor.getNumPrograms() - 1) % processor.getNumPrograms()); }; - next_.onClick = [this] { selectProgram((processor.getCurrentProgram() + 1) % processor.getNumPrograms()); }; - addAndMakeVisible(previous_); - addAndMakeVisible(next_); - - setResizable(false, false); - setSize(kWidth, kHeight); + content_.setInterceptsMouseClicks(false, true); + addAndMakeVisible(content_); + content_.addAndMakeVisible(plant_); + content_.addAndMakeVisible(frequency_); + content_.addAndMakeVisible(body_); + content_.addAndMakeVisible(row_); + scrim_.onClick = [this] { setSetupOpen(false); }; + content_.addChildComponent(scrim_); + content_.addChildComponent(setup_); + + header_.onProgramChosen = [this](int index) { selectProgram(index); }; + header_.onSetupClicked = [this] { setSetupOpen(!setupOpen()); }; + content_.addAndMakeVisible(header_); + + setWantsKeyboardFocus(true); + const float scale = bridge_.uiScale(); // read first: setting the limits resizes the window, which saves its scale + setResizable(true, true); + getConstrainer()->setFixedAspectRatio((double) kWidth / kHeight); + setResizeLimits(kWidth * 3 / 4, kHeight * 3 / 4, kWidth * 3 / 2, kHeight * 3 / 2); + layOut(); + setSize(juce::roundToInt(kWidth * scale), juce::roundToInt(kHeight * scale)); bridge_.spectrumTap().setActive(true); refreshDisplay(0.0f); lastTick_ = juce::Time::getMillisecondCounterHiRes(); - startTimerHz(30); + startTimerHz(60); } DroseraEditor::~DroseraEditor() { @@ -45,16 +55,26 @@ DroseraEditor::~DroseraEditor() { setLookAndFeel(nullptr); } -void DroseraEditor::setTab(int index) { - tabs_.setTab(index); - bridge_.setOpenTab(tabs_.tab()); -} - void DroseraEditor::selectProgram(int index) { if (!juce::isPositiveAndBelow(index, processor.getNumPrograms())) return; processor.setCurrentProgram(index); processor.updateHostDisplay(juce::AudioProcessorListener::ChangeDetails().withProgramChanged(true)); - presetBox_.setSelectedId(index + 1, juce::dontSendNotification); + header_.setProgram(index); +} + +void DroseraEditor::setSetupOpen(bool open) { + scrim_.setVisible(open); + setup_.setVisible(open); + if (open) setup_.toFront(false); + header_.setSetupOpen(open); +} + +bool DroseraEditor::keyPressed(const juce::KeyPress& key) { + if (key == juce::KeyPress::escapeKey && setupOpen()) { + setSetupOpen(false); + return true; + } + return false; } void DroseraEditor::timerCallback() { @@ -68,41 +88,61 @@ void DroseraEditor::refreshDisplay(float dtSeconds) { plant_.setFeedbackText(apvts_.getParameter("fbgain")->getCurrentValueAsText()); const bool midi = bridge_.midiPitchActive(); + const bool polyMidi = bridge_.polyMidiActive(); const auto frequency = drosera::readouts::frequency(midi, bridge_.soundingNote(), frequency_.parameter().getCurrentValueAsText().toStdString()); const auto body = drosera::readouts::body(bridge_.bodySnapped(), bridge_.bodySeconds(), body_.parameter().getCurrentValueAsText().toStdString()); - frequency_.show(frequency.value, frequency.tag); + if (bridge_.polyRunning()) { + float notes[3]; + for (int v = 0; v < 3; ++v) notes[v] = polyMidi ? bridge_.voiceNote(v) : bridge_.publishedNote(v); + frequency_.show(juce::String::fromUTF8(drosera::readouts::chord(notes, 3).c_str()), frequency.tag); + } else { + frequency_.show(frequency.value, frequency.tag); + } body_.show(body.value, body.tag); const bool warp = apvts_.getRawParameterValue("echohalve")->load() > 0.5f; const bool pong = apvts_.getRawParameterValue("echopong")->load() > 0.5f; - const bool polyMidi = bridge_.polyMidiActive(); - for (const auto& [id, knob] : tabs_.knobs()) { - const auto text = knob->parameter().getCurrentValueAsText(); - if (id == "interval2" || id == "interval3") { - const auto interval = drosera::readouts::interval(polyMidi, bridge_.voiceNote(id == "interval2" ? 1 : 2), text.toStdString()); - knob->show(interval.value, interval.tag); - continue; + for (const auto* knobs : { &row_.knobs(), &setup_.knobs() }) + for (const auto& [id, knob] : *knobs) { + const auto text = knob->parameter().getCurrentValueAsText(); + if (id == "interval2" || id == "interval3") { + const int voice = id == "interval2" ? 1 : 2; + const int semitones = juce::roundToInt(knob->parameter().convertFrom0to1(knob->parameter().getValue())); + const auto interval = polyMidi ? drosera::readouts::interval(true, bridge_.voiceNote(voice), text.toStdString()) + : drosera::readouts::playedInterval(bridge_.polyRunning(), bridge_.publishedNote(0), + bridge_.publishedNote(voice), semitones, text.toStdString()); + knob->show(interval.value, interval.tag); + continue; + } + knob->show(id == "echotime" ? juce::String(drosera::readouts::echoTime(warp, bridge_.echoSeconds(), text.toStdString())) : text, {}); } - if (id == "echodrive" || id == "echotone" || id == "echowidth" || id == "echolevel") { - const auto shaping = drosera::readouts::echoShaping(pong, text.toStdString()); - knob->show(shaping.value, shaping.tag); - continue; + + pulseSeconds_ += dtSeconds; + const bool sending = apvts_.getRawParameterValue("echosend")->load() > 0.0f; + row_.refresh(hz(apvts_, "hpf", 10.0f, 4000.0f), hz(apvts_, "lpf", 100.0f, 18000.0f), hz(apvts_, "echotone", 200.0f, 3200.0f), pong, + drosera::pulse::echoPulse(pulseSeconds_, bridge_.echoSeconds(), pong, sending)); + setup_.refresh(bridge_.polyRunning()); + + std::array voiceBands {-1, -1, -1}; + if (bridge_.polyRunning()) + for (int v = 0; v < 3; ++v) { + const float note = polyMidi ? bridge_.voiceNote(v) : bridge_.publishedNote(v); + if (note >= 0.0f) voiceBands[(size_t) v] = drosera::bandOfNote(note); } - knob->show(id == "echotime" ? juce::String(drosera::readouts::echoTime(warp, bridge_.echoSeconds(), text.toStdString())) : text, {}); - } + plant_.setVoiceBands(voiceBands); // Follow what the host changes. - const int program = processor.getCurrentProgram() + 1; - if (presetBox_.getSelectedId() != program) presetBox_.setSelectedId(program, juce::dontSendNotification); - const int storedTab = bridge_.openTab(); - if (storedTab != tabs_.tab() && juce::isPositiveAndBelow(storedTab, drosera::TabPanel::kNumTabs)) tabs_.setTab(storedTab); + header_.setProgram(processor.getCurrentProgram()); } const drosera::LabeledKnob* DroseraEditor::findKnob(const juce::String& paramId) const { if (paramId == "freq") return &frequency_; if (paramId == "body") return &body_; - const auto found = tabs_.knobs().find(paramId); - return found != tabs_.knobs().end() ? found->second.get() : nullptr; + for (const auto* knobs : { &row_.knobs(), &setup_.knobs() }) { + const auto found = knobs->find(paramId); + if (found != knobs->end()) return found->second.get(); + } + return nullptr; } juce::Slider* DroseraEditor::slider(const juce::String& paramId) { @@ -111,6 +151,11 @@ juce::Slider* DroseraEditor::slider(const juce::String& paramId) { return knob != nullptr ? &knob->slider() : nullptr; } +juce::Button* DroseraEditor::toggle(const juce::String& paramId) { + if (auto* button = row_.toggle(paramId)) return button; + return setup_.toggle(paramId); +} + juce::String DroseraEditor::readout(const juce::String& paramId) const { const auto* knob = findKnob(paramId); return knob != nullptr ? knob->readout() : juce::String(); @@ -122,19 +167,33 @@ juce::String DroseraEditor::tag(const juce::String& paramId) const { } void DroseraEditor::paint(juce::Graphics& g) { - g.fillAll(drosera::theme::background()); - g.setColour(drosera::theme::text()); - g.setFont(drosera::theme::titleFont()); - g.drawText("DROSERA", 26, 18, 260, 28, juce::Justification::centredLeft); + g.addTransform(contentTransform()); + g.setGradientFill(juce::ColourGradient(drosera::theme::backgroundCentre(), {450.0f, 240.0f}, + drosera::theme::backgroundEdge(), {450.0f, 240.0f + 560.0f}, true)); + g.fillAll(); } void DroseraEditor::resized() { - plant_.setBounds(0, 0, kWidth, kPlantHeight); - frequency_.setBounds(135 - 60, 132, 120, 138); - body_.setBounds(625 - 60, 132, 120, 138); - tabs_.setBounds(0, kTabsTop, kWidth, kHeight - kTabsTop); - - previous_.setBounds(kWidth - 26 - 220, 20, 24, 24); - presetBox_.setBounds(kWidth - 26 - 196, 20, 172, 24); - next_.setBounds(kWidth - 26 - 24, 20, 24, 24); + content_.setBounds(0, 0, kWidth, kHeight); + content_.setTransform(contentTransform()); + bridge_.setUiScale(juce::jlimit(0.75f, 1.5f, scale())); +} + +juce::AffineTransform DroseraEditor::contentTransform() const { + const float s = scale(); + return juce::AffineTransform::scale(s).translated(0.5f * ((float) getWidth() - kWidth * s), 0.5f * ((float) getHeight() - kHeight * s)); +} + +void DroseraEditor::layOut() { + plant_.setBounds(juce::Rectangle(520, 340).withCentre({kWidth / 2, kBandY})); + auto placeLarge = [](drosera::LabeledKnob& knob, int x) { + const int fromTop = drosera::LabeledKnob::radius(drosera::KnobSize::large) + drosera::LabeledKnob::margin(drosera::KnobSize::large); + knob.setBounds(x - 80, kBandY - fromTop, 160, 190); + }; + placeLarge(frequency_, 150); + placeLarge(body_, 750); + row_.setBounds(0, drosera::ControlRow::kTop, kWidth, kHeight - drosera::ControlRow::kTop); + header_.setBounds(0, 0, kWidth, drosera::HeaderBar::kHeight); + scrim_.setBounds(0, drosera::HeaderBar::kHeight, kWidth, drosera::SetupPanel::kTop - drosera::HeaderBar::kHeight); + setup_.setBounds(0, drosera::SetupPanel::kTop, kWidth, kHeight - drosera::SetupPanel::kTop); } diff --git a/src/plugin/PluginEditor.h b/src/plugin/PluginEditor.h index 961e472..d69b610 100644 --- a/src/plugin/PluginEditor.h +++ b/src/plugin/PluginEditor.h @@ -1,37 +1,55 @@ #pragma once -// Drosera's window: assembles the plant, the main knobs and the tabs, and runs the timer that keeps them honest. +// Drosera's window: assembles the header, the plant, the main knobs, the row of controls and the setup panel, and runs +// the timer that keeps them honest. #include +#include #include "EditorBridge.h" +#include "ui/ControlRow.h" #include "ui/DroseraLookAndFeel.h" +#include "ui/HeaderBar.h" +#include "ui/LabeledKnob.h" #include "ui/PlantView.h" -#include "ui/TabPanel.h" +#include "ui/SetupPanel.h" class DroseraEditor final : public juce::AudioProcessorEditor, private juce::Timer { public: DroseraEditor(juce::AudioProcessor&, juce::AudioProcessorValueTreeState&, EditorBridge&); ~DroseraEditor() override; - void setTab(int); void selectProgram(int index); - int tab() const { return tabs_.tab(); } - juce::Rectangle tabBounds(int index) const { return tabs_.tabBounds(index).translated(tabs_.getX(), tabs_.getY()); } - - juce::Rectangle toggleBounds(const juce::String& paramId) const { return tabs_.toggleBounds(paramId); } + void setSetupOpen(bool); + bool setupOpen() const { return setup_.isVisible(); } + /// The window's size over its 900×560 design. A window of another aspect, which some hosts force, fits the design + /// inside it, centred. + float scale() const { return juce::jmin((float) getWidth() / 900.0f, (float) getHeight() / 560.0f); } /// The timer's body: moves the plant on by dtSeconds and refreshes every readout. void refreshDisplay(float dtSeconds); juce::Slider* slider(const juce::String& paramId); + juce::Button* toggle(const juce::String& paramId); juce::String readout(const juce::String& paramId) const; juce::String tag(const juce::String& paramId) const; const drosera::PlantView& plant() const { return plant_; } + drosera::HeaderBar& header() { return header_; } void paint(juce::Graphics&) override; void resized() override; + bool keyPressed(const juce::KeyPress&) override; private: + /// Dims the window above the open setup panel; a click on it closes the panel. + struct Scrim final : juce::Component { + std::function onClick; + void paint(juce::Graphics& g) override { g.fillAll(juce::Colour(0x990b0809)); } + void mouseDown(const juce::MouseEvent&) override { if (onClick) onClick(); } + }; + void timerCallback() override; + /// Places every child in the 900×560 content, which never changes size: the window scales it. + void layOut(); + juce::AffineTransform contentTransform() const; const drosera::LabeledKnob* findKnob(const juce::String& paramId) const; drosera::DroseraLookAndFeel lookAndFeel_; @@ -39,11 +57,13 @@ class DroseraEditor final : public juce::AudioProcessorEditor, private juce::Tim EditorBridge& bridge_; drosera::PlantView plant_; juce::SliderParameterAttachment feedbackAttachment_; + juce::Component content_; // everything, laid out at 900×560 and scaled as a whole drosera::LabeledKnob frequency_, body_; - drosera::TabPanel tabs_; - juce::ComboBox presetBox_; - juce::TextButton previous_ {juce::String(juce::CharPointer_UTF8("\xe2\x80\xb9"))}, next_ {juce::String(juce::CharPointer_UTF8("\xe2\x80\xba"))}; - double lastTick_ = 0.0; + drosera::ControlRow row_; + Scrim scrim_; + drosera::SetupPanel setup_; + drosera::HeaderBar header_; + double lastTick_ = 0.0, pulseSeconds_ = 0.0; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(DroseraEditor) }; diff --git a/src/plugin/PluginProcessor.cpp b/src/plugin/PluginProcessor.cpp index d46ec9a..5816823 100644 --- a/src/plugin/PluginProcessor.cpp +++ b/src/plugin/PluginProcessor.cpp @@ -228,14 +228,16 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge void getStateInformation(juce::MemoryBlock& dest) override { auto state = apvts.copyState(); - state.setProperty(kTabProperty, uiTab.load(), nullptr); + state.setProperty(kScaleProperty, uiScale_.load(), nullptr); if (auto xml = state.createXml()) copyXmlToBinary(*xml, dest); } void setStateInformation(const void* data, int size) override { if (auto xml = getXmlFromBinary(data, size)) if (xml->hasTagName(apvts.state.getType())) { - uiTab.store(xml->getIntAttribute(kTabProperty, 0)); // an atomic: hosts may call this off the message thread + // An atomic: hosts may call this off the message thread. A missing or damaged scale opens at 100 %. + const double scale = xml->getDoubleAttribute(kScaleProperty, 1.0); + uiScale_.store(scale >= 0.75 && scale <= 1.5 ? (float) scale : 1.0f); apvts.replaceState(juce::ValueTree::fromXml(*xml)); currentProgram = juce::jlimit(0, getNumPrograms() - 1, (int) apvts.state.getProperty("program", 0)); stateJump.store(true); // a project must load onto the same drone every time: no glide from the defaults @@ -248,15 +250,18 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge float soundingNote() const override { return core->PublishedNote(); } bool polyMidiActive() const override { return polyMidiActive_.load(); } float voiceNote(int voice) const override { return voiceNotes_[voice].load(); } + float publishedNote(int voice) const override { return core->PublishedNote((size_t) voice); } + bool polyRunning() const override { return polyRaw->load() > 0.5f; } float bodySeconds() const override { return core->PublishedBodySeconds(); } float echoSeconds() const override { return DroseraCore::EchoTimeSeconds(raw[DroseraCore::EchoTime]->load(), halveRaw->load() > 0.5f); } double hostSampleRate() const override { return hostRate.load(); } drosera::SpectrumTap& spectrumTap() override { return tap; } - int openTab() const override { return uiTab.load(); } - void setOpenTab(int index) override { uiTab.store(index); } + float uiScale() const override { return uiScale_.load(); } + void setUiScale(float scale) override { uiScale_.store(scale); } + + static constexpr const char* kScaleProperty = "uiScale"; static constexpr int kMaxChunk = 1024; - static constexpr const char* kTabProperty = "uiTab"; static constexpr size_t kPrime = 256; static constexpr int kMaxHeld = 16; @@ -300,7 +305,7 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge midiPitchActive_.store(pitchOn); polyMidiActive_.store(polyMidi); for (int v = 0; v < VoiceAllocator::kVoices; ++v) - voiceNotes_[v].store(polyMidi && allocator.Note(v) != VoiceAllocator::kNoNote + voiceNotes_[v].store(polyMidi && allocator.Note(v) != VoiceAllocator::kNoNote && (!gateOn || allocator.Held(v)) // a gated voice is silent once released ? juce::jlimit(16.0f, 84.0f, (float) allocator.Note(v) + bendSemis) : -1.0f); if (!polyMidi) { @@ -486,7 +491,7 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge drosera::SpectrumTap tap; std::atomic hostRate { 0.0 }; - std::atomic uiTab { 0 }; + std::atomic uiScale_ { 1.0f }; // Sample-rate bridging state double ratio = 1.0, inRatio = 1.0; diff --git a/src/plugin/ui/ControlRow.cpp b/src/plugin/ui/ControlRow.cpp new file mode 100644 index 0000000..a2cb05f --- /dev/null +++ b/src/plugin/ui/ControlRow.cpp @@ -0,0 +1,112 @@ +#include "ControlRow.h" +#include +#include "DroseraLookAndFeel.h" +#include "Theme.h" + +namespace drosera { + +namespace { +struct Place { const char* id; const char* caption; int x; KnobSize size; KnobGroup group; }; + +// Centres in the window, from the mockup. The knobs' centres sit on y 466. +constexpr int kKnobY = 466, kCellWidth = 58, kCellHeight = 92; +const Place kPlaces[] = { + {"hpf", "Highpass", 66, KnobSize::small, KnobGroup::loop}, {"lpf", "Lowpass", 126, KnobSize::small, KnobGroup::loop}, + {"revmix", "Mix", 206, KnobSize::small, KnobGroup::loop}, {"revdecay", "Decay", 266, KnobSize::small, KnobGroup::loop}, + {"echodrive", "Drive", 360, KnobSize::mini, KnobGroup::echo}, {"echotone", "Tone", 420, KnobSize::mini, KnobGroup::echo}, + {"echowidth", "Width", 480, KnobSize::mini, KnobGroup::echo}, {"echolevel", "Level", 540, KnobSize::mini, KnobGroup::echo}, + {"echosend", "Send", 634, KnobSize::small, KnobGroup::echo}, {"echotime", "Time", 694, KnobSize::small, KnobGroup::echo}, + {"echofb", "Feedback", 754, KnobSize::small, KnobGroup::echo}, +}; +constexpr const char* kPongKnobs[] = {"echodrive", "echotone", "echowidth", "echolevel"}; + +juce::RangedAudioParameter& parameter(juce::AudioProcessorValueTreeState& apvts, const char* id) { + auto* p = apvts.getParameter(id); + jassert(p != nullptr); + return *p; +} + +/// In the row's coordinates, from the window's. +float rowY(float windowY) { return windowY - (float) ControlRow::kTop; } +} // namespace + +ControlRow::ControlRow(juce::AudioProcessorValueTreeState& apvts) + : modeAttachment_(parameter(apvts, "echopong"), mode_, nullptr), + warpAttachment_(parameter(apvts, "echohalve"), warp_, nullptr) { + for (const auto& place : kPlaces) { + knobs_[place.id] = std::make_unique(parameter(apvts, place.id), place.caption, place.size, place.group); + addAndMakeVisible(*knobs_[place.id]); + } + warp_.setClickingTogglesState(true); + warp_.setTitle("Echo Warp"); + warp_.getProperties().set(DroseraLookAndFeel::kGroup, "echo"); + addAndMakeVisible(mode_); + addAndMakeVisible(warp_); + arcs::bandArc(10.0f, 18000.0f, bandTicks_); + arcs::toneArc(800.0f, toneTicks_); +} + +juce::Button* ControlRow::toggle(const juce::String& paramId) { + if (paramId == "echopong") return &mode_; + if (paramId == "echohalve") return &warp_; + return nullptr; +} + +void ControlRow::refresh(float highpassHz, float lowpassHz, float toneHz, bool pong, pulse::Lit lit) { + for (const char* id : kPongKnobs) knobs_[id]->setDimmed(!pong); // setAlpha repaints only on a change + float band[arcs::kBandTicks], tone[arcs::kToneTicks]; + arcs::bandArc(highpassHz, lowpassHz, band); + arcs::toneArc(toneHz, tone); + const bool changed = pong != pong_ || lit != lit_ || !std::equal(band, band + arcs::kBandTicks, bandTicks_) + || !std::equal(tone, tone + arcs::kToneTicks, toneTicks_); + if (!changed) return; + std::copy(band, band + arcs::kBandTicks, bandTicks_); + std::copy(tone, tone + arcs::kToneTicks, toneTicks_); + pong_ = pong; + lit_ = lit; + repaint(); +} + +void ControlRow::paint(juce::Graphics& g) { + // The band arc over Highpass and Lowpass: ticks in the band that survives are full red. + for (int i = 0; i < arcs::kBandTicks; ++i) { + const float x = 46.0f + 100.0f * (float) i / (float) (arcs::kBandTicks - 1), base = rowY(430.0f); + const bool passes = bandTicks_[i] > arcs::kBandStub + 0.5f * arcs::kBandSpan; + g.setColour(passes ? theme::loop() : juce::Colour(0xff6d2a37)); + g.drawLine(x, base, x, base - bandTicks_[i], 1.5f); + } + // The Tone arc, dimmed with PONG's knobs. + g.setColour(theme::echo().withAlpha(pong_ ? 1.0f : 0.35f)); + for (int i = 0; i < arcs::kToneTicks; ++i) { + const float x = 404.0f + 32.0f * (float) i / (float) (arcs::kToneTicks - 1), base = rowY(432.0f); + g.drawLine(x, base, x, base - toneTicks_[i], 1.4f); + } + // The echo pulse beside Time: one dot, or two that take turns with PONG. + auto dot = [&](float x, bool on) { + g.setColour(theme::echo().withAlpha(on ? 1.0f : 0.2f)); + g.fillEllipse(juce::Rectangle(5.0f, 5.0f).withCentre({x, rowY(452.0f)})); + }; + if (pong_) { + dot(716.0f, lit_ == pulse::Lit::left); + dot(726.0f, lit_ == pulse::Lit::right); + } else { + dot(720.0f, lit_ == pulse::Lit::single); + } + + drawCartouche(g, "FILTER", 44.0f, 148.0f, rowY(534.0f)); + drawCartouche(g, "REVERB", 184.0f, 288.0f, rowY(534.0f)); + drawCartouche(g, "PONG", 340.0f, 560.0f, rowY(534.0f), pong_ ? 1.0f : 0.4f); + drawCartouche(g, "ECHO", 612.0f, 866.0f, rowY(534.0f)); +} + +void ControlRow::resized() { + for (const auto& place : kPlaces) { + auto& knob = *knobs_[place.id]; + const int top = kKnobY - kTop - LabeledKnob::radius(place.size) - LabeledKnob::margin(place.size); + knob.setBounds(place.x - kCellWidth / 2, top, kCellWidth, kCellHeight); + } + mode_.setBounds(juce::Rectangle(92, 22).withCentre({834, 458 - kTop})); + warp_.setBounds(juce::Rectangle(50, 18).withCentre({834, 490 - kTop})); +} + +} // namespace drosera diff --git a/src/plugin/ui/ControlRow.h b/src/plugin/ui/ControlRow.h new file mode 100644 index 0000000..94e0540 --- /dev/null +++ b/src/plugin/ui/ControlRow.h @@ -0,0 +1,44 @@ +#pragma once +// The row under the plant: FILTER and REVERB on the left, PONG in the centre, ECHO on the right. Everything played live +// that is not Frequency, Body or Feedback. + +#include +#include +#include +#include "EchoPulse.h" +#include "LabeledKnob.h" +#include "Pill.h" +#include "ResponseArcs.h" + +namespace drosera { + +class ControlRow final : public juce::Component { +public: + /// The row's top in the window. Its children are laid out from the mockup's window coordinates. + static constexpr int kTop = 400; + + explicit ControlRow(juce::AudioProcessorValueTreeState&); + + const std::map>& knobs() const { return knobs_; } + /// The CLASSIC / PONG switch and WARP, or nullptr for another ID. + juce::Button* toggle(const juce::String& paramId); + + /// Message thread: redraws the arcs, dims PONG's knobs in CLASSIC and lights the echo pulse. + void refresh(float highpassHz, float lowpassHz, float toneHz, bool pong, pulse::Lit); + + void paint(juce::Graphics&) override; + void resized() override; + +private: + std::map> knobs_; + ModeSwitch mode_; + juce::ToggleButton warp_ {"WARP"}; + juce::ButtonParameterAttachment modeAttachment_, warpAttachment_; + float bandTicks_[arcs::kBandTicks] {}, toneTicks_[arcs::kToneTicks] {}; + bool pong_ = false; + pulse::Lit lit_ = pulse::Lit::none; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ControlRow) +}; + +} // namespace drosera diff --git a/src/plugin/ui/DroseraLookAndFeel.cpp b/src/plugin/ui/DroseraLookAndFeel.cpp index 3f7dd28..8238f92 100644 --- a/src/plugin/ui/DroseraLookAndFeel.cpp +++ b/src/plugin/ui/DroseraLookAndFeel.cpp @@ -6,26 +6,36 @@ namespace drosera { DroseraLookAndFeel::DroseraLookAndFeel() { setColour(juce::Label::textColourId, theme::text()); setColour(juce::ComboBox::textColourId, theme::text()); - setColour(juce::ComboBox::arrowColourId, theme::dew()); + setColour(juce::ComboBox::arrowColourId, theme::echo()); setColour(juce::PopupMenu::backgroundColourId, theme::panel()); setColour(juce::PopupMenu::textColourId, theme::text()); setColour(juce::PopupMenu::highlightedBackgroundColourId, theme::track()); - setColour(juce::PopupMenu::highlightedTextColourId, theme::dew()); + setColour(juce::PopupMenu::highlightedTextColourId, theme::echo()); setColour(juce::TextButton::textColourOffId, theme::textDim()); - setColour(juce::TextButton::textColourOnId, theme::dew()); + setColour(juce::TextButton::textColourOnId, theme::echo()); } void DroseraLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int w, int h, float pos, float startAngle, float endAngle, juce::Slider& s) { + if (s.getProperties().contains(kDrawnByOwner)) return; const bool main = s.getProperties().contains(kMainControl); - const float stroke = main ? 3.5f : 2.5f; - const auto colour = main ? theme::dew() : theme::knob(); - const auto bounds = juce::Rectangle(x, y, w, h).toFloat().reduced(stroke); - const float radius = juce::jmin(bounds.getWidth(), bounds.getHeight()) * 0.5f; + const auto colour = s.getProperties()[kGroup].toString() == "loop" ? theme::loop() : theme::echo(); + const float stroke = main ? 4.0f : 2.6f; + const auto bounds = juce::Rectangle(x, y, w, h).toFloat(); + const float radius = juce::jmin(bounds.getWidth(), bounds.getHeight()) * 0.5f - (main ? 16.0f : 4.0f); const auto centre = bounds.getCentre(); const float angle = startAngle + pos * (endAngle - startAngle); - const juce::PathStrokeType arc(stroke, juce::PathStrokeType::curved, juce::PathStrokeType::rounded); + if (main) { // a fine scale outside the arc, every fifth tick longer + for (int i = 0; i <= 20; ++i) { + const float a = startAngle + (endAngle - startAngle) * (float) i / 20.0f; + const float outer = radius + (i % 5 == 0 ? 15.0f : 12.0f); + g.setColour((float) i / 20.0f <= pos ? colour.withAlpha(0.67f) : juce::Colour(0xff3a2c2e)); + g.drawLine({centre.getPointOnCircumference(radius + 9.0f, a), centre.getPointOnCircumference(outer, a)}, 1.2f); + } + } + + const juce::PathStrokeType arc(stroke, juce::PathStrokeType::curved, juce::PathStrokeType::rounded); juce::Path trackPath; trackPath.addCentredArc(centre.x, centre.y, radius, radius, 0.0f, startAngle, endAngle, true); g.setColour(theme::track()); @@ -33,31 +43,39 @@ void DroseraLookAndFeel::drawRotarySlider(juce::Graphics& g, int x, int y, int w juce::Path valuePath; valuePath.addCentredArc(centre.x, centre.y, radius, radius, 0.0f, startAngle, angle, true); + g.setColour(colour.withAlpha(0.18f)); // a soft glow: a wider stroke at low alpha, cheaper than a blur + g.strokePath(valuePath, juce::PathStrokeType(stroke * 3.0f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); g.setColour(colour); g.strokePath(valuePath, arc); - const auto tip = centre.getPointOnCircumference(radius * 0.95f, angle); - const auto root = centre.getPointOnCircumference(radius * 0.6f, angle); - g.drawLine({root, tip}, 2.0f); + const float capRadius = radius - stroke - 3.0f; + const auto cap = juce::Rectangle(2.0f * capRadius, 2.0f * capRadius).withCentre(centre); + g.setGradientFill(juce::ColourGradient(juce::Colour(0xff2f2426), centre.translated(-0.3f * radius, -0.35f * radius), + juce::Colour(0xff151012), centre.translated(0.0f, 0.82f * radius), true)); + g.fillEllipse(cap); + g.setColour(theme::hairline()); + g.drawEllipse(cap, 1.0f); + + g.setColour(theme::text()); + g.drawLine({centre.getPointOnCircumference(radius * 0.32f, angle), centre.getPointOnCircumference(radius - stroke - 7.0f, angle)}, + main ? 2.4f : 1.8f); } void DroseraLookAndFeel::drawToggleButton(juce::Graphics& g, juce::ToggleButton& b, bool highlighted, bool) { - const auto r = b.getLocalBounds().toFloat().reduced(1.0f); - const bool on = b.getToggleState(); - if (on) { - g.setColour(theme::dew()); - g.fillRoundedRectangle(r, r.getHeight() * 0.5f); - } - g.setColour(on ? theme::dew() : (highlighted ? theme::knob() : theme::track())); + const auto r = b.getLocalBounds().toFloat().reduced(0.5f); + const bool on = b.getToggleState(), echo = b.getProperties()[kGroup].toString() == "echo"; + g.setColour(on ? juce::Colour(echo ? 0xff5a3f1e : 0xff4a1d27) : juce::Colour(0xff1e1618)); + g.fillRoundedRectangle(r, r.getHeight() * 0.5f); + g.setColour(on ? (echo ? theme::echo() : theme::loop()) : (highlighted ? theme::cartouche() : theme::hairline())); g.drawRoundedRectangle(r, r.getHeight() * 0.5f, 1.0f); - g.setColour(on ? theme::background() : theme::text()); - g.setFont(theme::valueFont()); + g.setColour(on ? (echo ? theme::echo() : theme::text()) : theme::textDim()); + g.setFont(theme::tagFont()); g.drawText(b.getButtonText(), b.getLocalBounds(), juce::Justification::centred); } void DroseraLookAndFeel::drawButtonText(juce::Graphics& g, juce::TextButton& b, bool highlighted, bool) { - g.setColour(b.getToggleState() ? theme::dew() : (highlighted ? theme::text() : theme::textDim())); - g.setFont(theme::headFont()); + g.setColour(b.getToggleState() ? theme::echo() : (highlighted ? theme::text() : theme::textDim())); + g.setFont(theme::captionFont()); g.drawText(b.getButtonText(), b.getLocalBounds(), juce::Justification::centred); } diff --git a/src/plugin/ui/DroseraLookAndFeel.h b/src/plugin/ui/DroseraLookAndFeel.h index 57febf2..8f3f3a5 100644 --- a/src/plugin/ui/DroseraLookAndFeel.h +++ b/src/plugin/ui/DroseraLookAndFeel.h @@ -7,8 +7,12 @@ namespace drosera { class DroseraLookAndFeel final : public juce::LookAndFeel_V4 { public: - /// A slider with this property set is drawn as a main control, larger and in the dew colour. + /// A slider with this property set is drawn as a main control, larger and with a scale. static constexpr const char* kMainControl = "mainControl"; + /// "loop" or "echo": the colour of the knob's arc. + static constexpr const char* kGroup = "group"; + /// A slider with this property set is drawn by its owner, such as Feedback on the plant. + static constexpr const char* kDrawnByOwner = "drawnByOwner"; DroseraLookAndFeel(); diff --git a/src/plugin/ui/EchoPulse.h b/src/plugin/ui/EchoPulse.h new file mode 100644 index 0000000..e0a921b --- /dev/null +++ b/src/plugin/ui/EchoPulse.h @@ -0,0 +1,22 @@ +#pragma once +// The dot beside Echo Time, blinking at the real time between repeats. No JUCE, so it can be tested alone. + +#include + +namespace drosera::pulse { + +constexpr double kFlashSeconds = 0.08; + +enum class Lit { none, single, left, right }; + +/// With PONG the repeats bounce, so the dots take turns: left on even repeats, right on odd ones. +inline Lit echoPulse(double seconds, double periodSeconds, bool pong, bool sending) { + if (!sending) return Lit::none; + const double period = periodSeconds > 0.05 ? periodSeconds : 0.05; + const double repeat = std::floor(seconds / period); + if (seconds - repeat * period >= kFlashSeconds) return Lit::none; + if (!pong) return Lit::single; + return std::fmod(repeat, 2.0) == 0.0 ? Lit::left : Lit::right; +} + +} // namespace drosera::pulse diff --git a/src/plugin/ui/HeaderBar.cpp b/src/plugin/ui/HeaderBar.cpp new file mode 100644 index 0000000..c04a81e --- /dev/null +++ b/src/plugin/ui/HeaderBar.cpp @@ -0,0 +1,99 @@ +#include "HeaderBar.h" +#include "Theme.h" + +namespace drosera { + +namespace { +// From the mockup, in the window's coordinates. +const juce::Rectangle kPresetField {40, 30, 210, 30}, kSetupField {650, 30, 210, 30}; +constexpr int kArrowWidth = 30; + +void drawField(juce::Graphics& g, juce::Rectangle field, const juce::String& caption, bool open) { + g.setFont(theme::captionFont()); + g.setColour(juce::Colour(0xff6e605c)); + g.drawText(caption, field.getX() + 2, field.getY() - 18, field.getWidth(), 14, juce::Justification::centredLeft); + const auto r = field.toFloat().reduced(0.5f); + g.setColour(juce::Colour(open ? 0xff1f1719 : 0xff181214)); + g.fillRoundedRectangle(r, 7.0f); + g.setColour(open ? juce::Colour(0xff5a4436) : theme::hairline()); + g.drawRoundedRectangle(r, 7.0f, 1.0f); +} +} // namespace + +HeaderBar::HeaderBar(juce::AudioProcessor& processor) : processor_(processor), program_(processor.getCurrentProgram()) { + const int count = processor_.getNumPrograms(); + previous_.onClick = [this, count] { choose((processor_.getCurrentProgram() + count - 1) % count); }; + next_.onClick = [this, count] { choose((processor_.getCurrentProgram() + 1) % count); }; + preset_.onClick = [this] { + presetMenu().showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&preset_).withMinimumWidth(kPresetField.getWidth()), + [safe = juce::Component::SafePointer(this)](int result) { + if (safe != nullptr && result > 0) safe->choose(result - 1); + }); + }; + setup_.onClick = [this] { if (onSetupClicked) onSetupClicked(); }; + for (auto* area : {&previous_, &next_, &preset_, &setup_}) addAndMakeVisible(*area); +} + +void HeaderBar::choose(int index) { + if (onProgramChosen) onProgramChosen(index); +} + +void HeaderBar::setProgram(int index) { + if (index == program_) return; + program_ = index; + repaint(); +} + +void HeaderBar::setSetupOpen(bool open) { + if (open == setupOpen_) return; + setupOpen_ = open; + repaint(); +} + +juce::PopupMenu HeaderBar::presetMenu() const { + juce::PopupMenu menu; + for (int i = 0; i < processor_.getNumPrograms(); ++i) menu.addItem(i + 1, processor_.getProgramName(i), true, i == program_); + return menu; +} + +void HeaderBar::paint(juce::Graphics& g) { + g.setFont(theme::titleFont()); + g.setColour(juce::Colour(0xffe6d4c6)); + g.drawText("DROSERA", juce::Rectangle(300, 34).withCentre({450, 46}), juce::Justification::centred); + + const auto p = kPresetField; + drawField(g, p, "PRESET", false); + g.setColour(theme::hairline()); + g.drawVerticalLine(p.getX() + kArrowWidth, (float) p.getY() + 6.0f, (float) p.getBottom() - 6.0f); + g.drawVerticalLine(p.getRight() - kArrowWidth, (float) p.getY() + 6.0f, (float) p.getBottom() - 6.0f); + g.setFont(juce::FontOptions(16.0f)); + g.setColour(theme::textDim()); + g.drawText(juce::String::fromUTF8("\xe2\x80\xb9"), p.withWidth(kArrowWidth), juce::Justification::centred); + g.drawText(juce::String::fromUTF8("\xe2\x80\xba"), p.withTrimmedLeft(p.getWidth() - kArrowWidth), juce::Justification::centred); + const auto name = p.reduced(kArrowWidth + 6, 0); + g.setFont(theme::largeLabelFont().withHeight(12.5f)); + g.setColour(theme::text()); + g.drawText(processor_.getProgramName(program_), name.withTrimmedRight(14), juce::Justification::centred, true); + g.setFont(juce::FontOptions(10.0f)); + g.setColour(theme::textDim()); + g.drawText(juce::String::fromUTF8("\xe2\x96\xbe"), name.withTrimmedLeft(name.getWidth() - 12), juce::Justification::centred); + + const auto s = kSetupField; + drawField(g, s, "SETUP", setupOpen_); + g.setFont(theme::labelFont().withHeight(12.0f)); + g.setColour(setupOpen_ ? theme::echo() : theme::text()); + g.drawText(juce::String::fromUTF8("Strings \xc2\xb7 Output \xc2\xb7 MIDI"), s.withTrimmedLeft(14), juce::Justification::centredLeft); + g.setFont(juce::FontOptions(10.0f)); + g.setColour(theme::textDim()); + g.drawText(juce::String::fromUTF8(setupOpen_ ? "\xe2\x96\xb4" : "\xe2\x96\xbe"), s.withTrimmedLeft(s.getWidth() - 30), juce::Justification::centred); +} + +void HeaderBar::resized() { + const auto p = kPresetField; + previous_.setBounds(p.withWidth(kArrowWidth)); + next_.setBounds(p.withTrimmedLeft(p.getWidth() - kArrowWidth)); + preset_.setBounds(p.reduced(kArrowWidth, 0)); + setup_.setBounds(kSetupField); +} + +} // namespace drosera diff --git a/src/plugin/ui/HeaderBar.h b/src/plugin/ui/HeaderBar.h new file mode 100644 index 0000000..d103286 --- /dev/null +++ b/src/plugin/ui/HeaderBar.h @@ -0,0 +1,50 @@ +#pragma once +// The header: the PRESET field on the left, the name in the centre, and the SETUP field that opens the setup panel. + +#include +#include +#include + +namespace drosera { + +class HeaderBar final : public juce::Component { +public: + static constexpr int kHeight = 70; + + explicit HeaderBar(juce::AudioProcessor&); + + std::function onProgramChosen; + std::function onSetupClicked; + + void setProgram(int index); + void setSetupOpen(bool); + + juce::Button& previousButton() { return previous_; } + juce::Button& nextButton() { return next_; } + juce::Button& presetButton() { return preset_; } + juce::Button& setupButton() { return setup_; } + + /// Every preset, the current one ticked. Item IDs are the program's index plus one. + juce::PopupMenu presetMenu() const; + + void paint(juce::Graphics&) override; + void resized() override; + +private: + /// A click target: HeaderBar draws the fields themselves. + struct Area final : juce::Button { + explicit Area(const juce::String& title) : juce::Button(title) { setTitle(title); } + void paintButton(juce::Graphics&, bool, bool) override {} + }; + + void choose(int index); + + juce::AudioProcessor& processor_; + Area previous_ {"Previous preset"}, next_ {"Next preset"}, preset_ {"Preset"}, setup_ {"Strings, Output and MIDI"}; + int program_ = 0; + bool setupOpen_ = false; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(HeaderBar) +}; + +} // namespace drosera diff --git a/src/plugin/ui/LabeledKnob.cpp b/src/plugin/ui/LabeledKnob.cpp new file mode 100644 index 0000000..707da76 --- /dev/null +++ b/src/plugin/ui/LabeledKnob.cpp @@ -0,0 +1,60 @@ +#include "LabeledKnob.h" +#include "DroseraLookAndFeel.h" +#include "Theme.h" + +namespace drosera { + +namespace { +juce::Label& style(juce::Label& label, const juce::Font& font, juce::Colour colour) { + label.setFont(font); + label.setColour(juce::Label::textColourId, colour); + label.setJustificationType(juce::Justification::centred); + label.setInterceptsMouseClicks(false, false); + label.setBorderSize({}); + return label; +} +} // namespace + +LabeledKnob::LabeledKnob(juce::RangedAudioParameter& parameter, const juce::String& caption, KnobSize size, KnobGroup group) + : parameter_(parameter), size_(size), attachment_(parameter, slider_, nullptr) { + slider_.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); + slider_.setRotaryParameters(juce::degreesToRadians(225.0f), juce::degreesToRadians(495.0f), true); + slider_.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + slider_.setTitle(caption); + if (size == KnobSize::large) slider_.getProperties().set(DroseraLookAndFeel::kMainControl, true); + slider_.getProperties().set(DroseraLookAndFeel::kGroup, group == KnobGroup::loop ? "loop" : "echo"); + addAndMakeVisible(slider_); + + const bool large = size == KnobSize::large; + caption_.setText(caption, juce::dontSendNotification); + addAndMakeVisible(style(caption_, large ? theme::largeLabelFont() : theme::labelFont(), theme::text())); + addAndMakeVisible(style(value_, large ? theme::largeValueFont() : theme::valueFont(), theme::textDim())); + addAndMakeVisible(style(tag_, theme::tagFont(), theme::echo())); +} + +void LabeledKnob::show(const juce::String& value, const juce::String& tag) { + value_.setText(value, juce::dontSendNotification); + tag_.setText(tag, juce::dontSendNotification); + slider_.setAlpha(tag.isEmpty() ? 1.0f : 0.45f); +} + +void LabeledKnob::setDimmed(bool dimmed) { + setAlpha(dimmed ? 0.35f : 1.0f); +} + +juce::Point LabeledKnob::knobCentre() const { + return { getWidth() / 2, radius(size_) + margin(size_) }; +} + +void LabeledKnob::resized() { + const bool large = size_ == KnobSize::large; + const int side = 2 * (radius(size_) + margin(size_)); + auto r = getLocalBounds(); + slider_.setBounds(r.removeFromTop(side).withSizeKeepingCentre(side, side)); + if (large) r.removeFromTop(6); + caption_.setBounds(r.removeFromTop(large ? 18 : 15)); + value_.setBounds(r.removeFromTop(large ? 16 : 13)); + tag_.setBounds(r.removeFromTop(13)); +} + +} // namespace drosera diff --git a/src/plugin/ui/LabeledKnob.h b/src/plugin/ui/LabeledKnob.h new file mode 100644 index 0000000..a4bfb5a --- /dev/null +++ b/src/plugin/ui/LabeledKnob.h @@ -0,0 +1,43 @@ +#pragma once +// A knob with its name and readout, drawn in its group's colour. + +#include +#include + +namespace drosera { + +enum class KnobSize { large, small, mini }; +/// Loop knobs change what the drone sustains, echo knobs colour what comes out of it. +enum class KnobGroup { loop, echo }; + +class LabeledKnob final : public juce::Component { +public: + LabeledKnob(juce::RangedAudioParameter&, const juce::String& caption, KnobSize, KnobGroup); + + static int radius(KnobSize size) { return size == KnobSize::large ? 48 : size == KnobSize::small ? 20 : 18; } + /// The space the knob's drawing needs around its radius: the large knobs carry a scale. + static int margin(KnobSize size) { return size == KnobSize::large ? 16 : 4; } + + juce::Slider& slider() { return slider_; } + juce::RangedAudioParameter& parameter() { return parameter_; } + /// Dims the knob while what sounds is not its position. It stays usable: moving it can take control back. + void show(const juce::String& value, const juce::String& tag); + /// Dims the whole knob while it has no effect, such as PONG's knobs in CLASSIC. It stays usable. + void setDimmed(bool); + juce::String readout() const { return value_.getText(); } + juce::String tag() const { return tag_.getText(); } + + /// Where the knob's centre sits in this component, for laying it out by centre. + juce::Point knobCentre() const; + + void resized() override; + +private: + juce::RangedAudioParameter& parameter_; + const KnobSize size_; + juce::Slider slider_; + juce::Label caption_, value_, tag_; + juce::SliderParameterAttachment attachment_; +}; + +} // namespace drosera diff --git a/src/plugin/ui/Pill.h b/src/plugin/ui/Pill.h new file mode 100644 index 0000000..9ba918b --- /dev/null +++ b/src/plugin/ui/Pill.h @@ -0,0 +1,53 @@ +#pragma once +// The pill-shaped pieces: the CLASSIC / PONG switch, and the cartouche that names a group of controls. + +#include +#include "Theme.h" + +namespace drosera { + +/// Echo Pong as a two-part pill: CLASSIC on the left, PONG on the right. A click switches it. +class ModeSwitch final : public juce::Button { +public: + ModeSwitch() : juce::Button("Echo Pong") { + setClickingTogglesState(true); + setTitle("Echo Pong"); + } + + void paintButton(juce::Graphics& g, bool highlighted, bool) override { + const auto r = getLocalBounds().toFloat().reduced(0.5f); + const bool pong = getToggleState(); + g.setColour(juce::Colour(0xff1e1618)); + g.fillRoundedRectangle(r, r.getHeight() * 0.5f); + g.setColour(highlighted ? theme::cartouche() : theme::hairline()); + g.drawRoundedRectangle(r, r.getHeight() * 0.5f, 1.0f); + + const auto half = r.reduced(2.0f).withWidth(r.getWidth() * 0.5f - 3.0f); + const auto knob = pong ? half.withX(r.getCentreX() + 1.0f) : half; + g.setColour(pong ? juce::Colour(0xff5a3f1e) : juce::Colour(0xff4a1d27)); + g.fillRoundedRectangle(knob, knob.getHeight() * 0.5f); + + g.setFont(theme::tagFont()); + g.setColour(pong ? theme::textDim() : theme::text()); + g.drawText("CLASSIC", r.withWidth(r.getWidth() * 0.5f), juce::Justification::centred); + g.setColour(pong ? theme::echo() : theme::textDim()); + g.drawText("PONG", r.withTrimmedLeft(r.getWidth() * 0.5f), juce::Justification::centred); + } +}; + +/// A hairline pill around the group's name, with fine rules out to the group's edges. +inline void drawCartouche(juce::Graphics& g, const juce::String& name, float x0, float x1, float y, float alpha = 1.0f) { + const auto font = theme::captionFont(); + const float width = juce::GlyphArrangement::getStringWidth(font, name) + 22.0f, height = 18.0f, centre = 0.5f * (x0 + x1); + const auto pill = juce::Rectangle(width, height).withCentre({centre, y}); + g.setColour(theme::cartouche().withMultipliedAlpha(alpha)); + g.drawRoundedRectangle(pill, height * 0.5f, 1.0f); + g.setColour(juce::Colour(0xff2a2022).withMultipliedAlpha(alpha)); + g.drawHorizontalLine(juce::roundToInt(y), x0, pill.getX() - 6.0f); + g.drawHorizontalLine(juce::roundToInt(y), pill.getRight() + 6.0f, x1); + g.setFont(font); + g.setColour(theme::textDim().withMultipliedAlpha(alpha)); + g.drawText(name, pill.translated(1.5f, 0.5f), juce::Justification::centred); // the letter spacing trails the last letter +} + +} // namespace drosera diff --git a/src/plugin/ui/PlantView.cpp b/src/plugin/ui/PlantView.cpp index 81d8ff7..b394969 100644 --- a/src/plugin/ui/PlantView.cpp +++ b/src/plugin/ui/PlantView.cpp @@ -6,11 +6,16 @@ namespace drosera { namespace { -// In the component's coordinates, which are the window's. -const juce::Point kCentre { 380.0f, 180.0f }; -constexpr float kInner = 54.0f, kReach = 66.0f; constexpr float kRepaintChange = 0.002f; // of a tentacle's travel constexpr float kStoppedAfterSeconds = 0.2f; // longer than an 8192-sample block at 44.1 kHz +constexpr float kFeedbackRing = 32.0f; + +/// The leaf blade's slightly irregular rim. +float leafRadius(float angle) { + return 50.0f * (1.0f + 0.05f * std::sin(3.0f * angle + 1.1f) + 0.035f * std::sin(5.0f * angle + 0.4f) + 0.02f * std::sin(8.0f * angle)); +} + +juce::Colour mixed(juce::Colour a, juce::Colour b, float t) { return a.interpolatedWith(b, t); } } // namespace PlantView::PlantView() { @@ -22,8 +27,28 @@ PlantView::PlantView() { feedback_.setRotaryParameters(juce::degreesToRadians(225.0f), juce::degreesToRadians(495.0f), true); feedback_.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); feedback_.setTitle("Feedback"); - feedback_.getProperties().set("mainControl", true); // DroseraLookAndFeel::kMainControl + feedback_.getProperties().set("drawnByOwner", true); // DroseraLookAndFeel::kDrawnByOwner: the plant draws its ring + feedback_.onValueChange = [this] { repaint(); }; addAndMakeVisible(feedback_); + + uint32_t seed = 17; // a fixed seed, so the plant has the same shape every time + auto next = [&seed] { seed = seed * 1664525u + 1013904223u; return (float) (seed >> 8) / (float) (1u << 24); }; + for (int j = 0; j < kNumBands; ++j) { + auto& t = tentacles_[(size_t) j]; + t.angle = juce::MathConstants::twoPi * (float) j / (float) kNumBands - juce::MathConstants::halfPi + (next() - 0.5f) * 0.09f; + t.base = 20.0f + 8.0f * next(); // short at rest, so the sound's length stands out from the shape's + t.reach = 130.0f + 40.0f * next(); + t.curl = (next() < 0.5f ? -1.0f : 1.0f) * (0.45f + 0.5f * next()); + t.sway = (next() - 0.5f) * 0.25f; + t.width = 2.1f + 0.9f * next(); + t.drop = 0.7f + 0.6f * next(); + } +} + +void PlantView::setVoiceBands(const std::array& bands) { + if (bands == voiceBands_) return; + voiceBands_ = bands; + repaint(); } void PlantView::setFeedbackText(const juce::String& text) { @@ -67,41 +92,128 @@ void PlantView::update(SpectrumTap& tap, double sampleRate, float dtSeconds) { } void PlantView::resized() { - const float size = 2.0f * (kInner - 12.0f); - feedback_.setBounds(juce::Rectangle(size, size).withCentre(kCentre).toNearestInt()); + feedback_.setBounds(juce::Rectangle(64, 64).withCentre(getLocalBounds().getCentre())); +} + +float PlantView::shape(int tentacle, float amount, std::array, kSegments + 1>& points) const { + const auto& t = tentacles_[(size_t) tentacle]; + const auto centre = getLocalBounds().getCentre().toFloat(); + const float root = leafRadius(t.angle) - 3.0f, radius = (1.5f + 4.3f * amount) * t.drop; + const float curl = t.curl * (1.0f - 0.8f * amount); + auto grow = [&](float length) { + points[0] = centre.getPointOnCircumference(root, t.angle + juce::MathConstants::halfPi); + for (int k = 1; k <= kSegments; ++k) { + const float u = (float) k / (float) kSegments; + const float heading = t.angle + t.sway * (1.0f - u) + curl * u * u; + points[(size_t) k] = points[(size_t) k - 1] + juce::Point(std::cos(heading), std::sin(heading)) * (length / (float) kSegments); + } + }; + // Wider than tall, so the tips stay clear of the header and the row below. + float length = (t.base + t.reach * amount) * (1.0f - 0.22f * std::abs(std::sin(t.angle))); + grow(length); + // A loud tentacle whose tip and glow would pass the edge is shortened until they fit, rather than cut off. + const auto inside = getLocalBounds().toFloat(); + const float glow = 2.2f * radius; + for (int tries = 0; tries < 12 && !inside.isEmpty() + && !inside.contains(juce::Rectangle(2.0f * glow, 2.0f * glow).withCentre(points.back())); ++tries) + grow(length *= 0.92f); + return radius; +} + +float PlantView::reach(int tentacle, float amount) const { + std::array, kSegments + 1> points; + shape(tentacle, amount, points); + return points.front().getDistanceFrom(points.back()); +} + +juce::Rectangle PlantView::reachAt(float amount) const { + juce::Rectangle reach; + std::array, kSegments + 1> points; + for (int j = 0; j < kNumBands; ++j) { + const float glow = 2.2f * shape(j, amount, points); + const auto drop = juce::Rectangle(2.0f * glow, 2.0f * glow).withCentre(points.back()); + reach = j == 0 ? drop : reach.getUnion(drop); + } + return reach; } void PlantView::paint(juce::Graphics& g) { - const float glow = kInner + kReach + 22.0f; - g.setGradientFill(juce::ColourGradient(theme::heart().withAlpha(0.75f), kCentre, - theme::background().withAlpha(0.0f), kCentre.translated(glow, 0.0f), true)); - g.fillEllipse(juce::Rectangle(2.0f * glow, 2.0f * glow).withCentre(kCentre)); - - for (int i = 0; i < kNumBands; ++i) { - const float amount = bandAmount(levels_[(size_t) i]); - drawn_[(size_t) i] = amount; - const float angle = juce::MathConstants::twoPi * (float) i / (float) kNumBands; // lowest band at the top - const auto root = kCentre.getPointOnCircumference(kInner + 3.0f, angle); - const auto tip = kCentre.getPointOnCircumference(kInner + 8.0f + kReach * amount, angle); - g.setColour(theme::stalk()); - g.drawLine({root, tip}, 1.4f); - const float radius = 1.8f + 3.4f * amount; - g.setColour(theme::dew().withAlpha(0.35f + 0.65f * amount)); - g.fillEllipse(juce::Rectangle(2.0f * radius, 2.0f * radius).withCentre(tip)); + const auto centre = getLocalBounds().getCentre().toFloat(); + float mean = 0.0f; + for (float level : levels_) mean += bandAmount(level); + mean /= (float) kNumBands; + + for (int j = 0; j < kNumBands; ++j) { + const int band = tentacleBand(j); + const float amount = bandAmount(levels_[(size_t) band]); + drawn_[(size_t) band] = amount; + + std::array, kSegments + 1> points; + const float radius = shape(j, amount, points); + const auto& t = tentacles_[(size_t) j]; + for (int k = 1; k <= kSegments; ++k) { + const float from = (float) (k - 1) / (float) kSegments; + g.setColour(mixed(juce::Colour(0xff6e1a2a), juce::Colour(0xffd64a62), from).withAlpha(0.7f + 0.3f * amount)); + g.drawLine({points[(size_t) k - 1], points[(size_t) k]}, juce::jmap(from, t.width + 0.8f * amount, 0.7f)); + } + const auto point = points.back(); + for (const float halo : {2.2f, 1.5f}) { // a glow of translucent rings, cheaper than a blur + g.setColour(theme::echo().withAlpha(0.1f * amount)); + g.fillEllipse(juce::Rectangle(2.0f * radius * halo, 2.0f * radius * halo).withCentre(point)); + } + juce::ColourGradient dew(juce::Colour(0xfffff6e4).withAlpha(0.5f + 0.5f * amount), point.translated(-0.35f * radius, -0.35f * radius), + juce::Colour(0xffd6784a).withAlpha(0.35f + 0.4f * amount), point.translated(radius, 0.0f), true); + dew.addColour(0.45, theme::echo().withAlpha(0.45f + 0.55f * amount)); + g.setGradientFill(dew); + g.fillEllipse(juce::Rectangle(2.0f * radius, 2.0f * radius).withCentre(point)); + if (isMarked(band)) { + g.setColour(theme::text()); + g.drawEllipse(juce::Rectangle(2.0f * radius + 6.0f, 2.0f * radius + 6.0f).withCentre(point), 1.0f); + } + } + + // The leaf blade: no outline, it fades into the background. + juce::Path leaf; + for (int i = 0; i <= 72; ++i) { + const float a = juce::MathConstants::twoPi * (float) i / 72.0f; + const auto p = centre + juce::Point(std::cos(a), std::sin(a)) * leafRadius(a); + i == 0 ? leaf.startNewSubPath(p) : leaf.lineTo(p); + } + leaf.closeSubPath(); + juce::ColourGradient blade(juce::Colour::fromRGB((juce::uint8) (110.0f + 40.0f * mean), 30, 48), centre.translated(-10.0f, -12.0f), + juce::Colour(0x002c0b14), centre.translated(56.0f, 0.0f), true); + blade.addColour(0.6, juce::Colour(0xff4d1222)); + g.setGradientFill(blade); + g.fillPath(leaf); + g.setColour(juce::Colour(0x59a0384c)); + for (int i = 0; i < 9; ++i) { + const float a = juce::MathConstants::twoPi * (float) i / 9.0f + 0.3f; + juce::Path vein; + vein.startNewSubPath(centre + juce::Point(std::cos(a), std::sin(a)) * 8.0f); + vein.quadraticTo(centre + juce::Point(std::cos(a + 0.25f), std::sin(a + 0.25f)) * 28.0f, + centre + juce::Point(std::cos(a), std::sin(a)) * 46.0f); + g.strokePath(vein, juce::PathStrokeType(0.8f)); } - const auto disc = juce::Rectangle(2.0f * kInner, 2.0f * kInner).withCentre(kCentre); - g.setColour(theme::panel()); - g.fillEllipse(disc); - g.setColour(theme::track()); - g.drawEllipse(disc, 1.5f); + // Feedback, drawn on the leaf. + const float start = juce::degreesToRadians(225.0f), end = juce::degreesToRadians(495.0f); + const float angle = start + (float) feedback_.valueToProportionOfLength(feedback_.getValue()) * (end - start); + juce::Path track, value; + track.addCentredArc(centre.x, centre.y, kFeedbackRing, kFeedbackRing, 0.0f, start, end, true); + value.addCentredArc(centre.x, centre.y, kFeedbackRing, kFeedbackRing, 0.0f, start, angle, true); + g.setColour(juce::Colour(0x8c14060a)); + g.strokePath(track, juce::PathStrokeType(3.0f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + g.setColour(theme::loop().withAlpha(0.2f + 0.3f * mean)); + g.strokePath(value, juce::PathStrokeType(9.0f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + g.setColour(juce::Colour(0xffee6a80)); + g.strokePath(value, juce::PathStrokeType(3.0f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); g.setColour(theme::text()); - g.setFont(juce::FontOptions(8.5f, juce::Font::bold)); - g.drawText("FEEDBACK", juce::Rectangle(80.0f, 12.0f).withCentre(kCentre.translated(0.0f, -9.0f)), juce::Justification::centred); - g.setColour(theme::dew()); - g.setFont(juce::FontOptions(13.0f)); - g.drawText(feedbackText_, juce::Rectangle(70.0f, 18.0f).withCentre(kCentre.translated(0.0f, 5.0f)), juce::Justification::centred); + g.setFont(theme::labelFont().withHeight(10.5f)); + g.drawText("Feedback", juce::Rectangle(70.0f, 14.0f).withCentre(centre.translated(0.0f, -6.0f)), juce::Justification::centred); + g.setColour(theme::echo()); + g.setFont(theme::labelFont()); + g.drawText(feedbackText_, juce::Rectangle(70.0f, 14.0f).withCentre(centre.translated(0.0f, 9.0f)), juce::Justification::centred); } } // namespace drosera diff --git a/src/plugin/ui/PlantView.h b/src/plugin/ui/PlantView.h index 43f7f90..4b9afc4 100644 --- a/src/plugin/ui/PlantView.h +++ b/src/plugin/ui/PlantView.h @@ -1,9 +1,11 @@ #pragma once -// The sundew seen from above. Each tentacle is a frequency band, and its drop of dew swells with the real sound: there is -// no invented motion, so a silent plugin shows a still plant at rest. Holds the Feedback knob at its heart. +// The sundew seen from above. Each tentacle is a frequency band: at rest it is short and curled, and the real sound +// unfurls it and swells its drop of dew. There is no invented motion, so a silent plugin shows a still, closed plant. +// Holds the Feedback knob at its heart. #include #include +#include #include #include "SpectrumBands.h" #include "SpectrumTap.h" @@ -22,6 +24,17 @@ class PlantView final : public juce::Component { float level(int band) const { return levels_[(size_t) band]; } + /// Bands run in order around the leaf, the lowest at the top and clockwise, so a change of pitch moves the light + /// around the plant. + static constexpr int tentacleBand(int tentacle) { return tentacle; } + /// The bands holding the sounding strings' notes get a ring on their drop. -1 is no string. + void setVoiceBands(const std::array&); + /// What the tentacles and their drops' glow cover with every band at this amount, in the component's coordinates. + juce::Rectangle reachAt(float amount) const; + /// How far the tentacle's tip stands from its root at this band amount. + float reach(int tentacle, float amount) const; + bool isMarked(int band) const { return std::find(voiceBands_.begin(), voiceBands_.end(), band) != voiceBands_.end(); } + void paint(juce::Graphics&) override; void resized() override; @@ -38,6 +51,15 @@ class PlantView final : public juce::Component { std::array drawn_ {}; float pendingSeconds_ = 0.0f; // time since the levels last moved + /// Each tentacle's shape, fixed so the plant is the same every time. + struct Tentacle { float angle, base, reach, curl, sway, width, drop; }; + std::array tentacles_ {}; + std::array voiceBands_ {-1, -1, -1}; + + static constexpr int kSegments = 10; + /// The tentacle's points from root to tip at this amount, and its drop's radius. + float shape(int tentacle, float amount, std::array, kSegments + 1>& points) const; + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(PlantView) }; diff --git a/src/plugin/ui/Readouts.h b/src/plugin/ui/Readouts.h index 770dac9..e69a0e3 100644 --- a/src/plugin/ui/Readouts.h +++ b/src/plugin/ui/Readouts.h @@ -33,6 +33,26 @@ inline Text interval(bool polyMidi, float note, const std::string& knobText) { return {note < 0.0f ? "-" : noteName(note), "MIDI"}; } +/// With POLY from the knobs, a voice's note is held between the engine's limits, so a wide interval can sound narrower +/// than the knob says. A negative note is a voice with nothing to play. +inline Text playedInterval(bool polyRunning, float rootNote, float voiceNote, int knobSemitones, const std::string& knobText) { + if (!polyRunning || voiceNote < 0.0f) return {knobText, ""}; + const long actual = std::lround(voiceNote - rootNote); + if (actual == knobSemitones) return {knobText, ""}; + return {format("%+.0f st", (double) actual), "LIMIT"}; +} + +/// The sounding voices' notes, such as "C3 · G3 · C4". Negative notes are silent voices, left out. +inline std::string chord(const float* notes, int count) { + std::string out; + for (int i = 0; i < count; ++i) { + if (notes[i] < 0.0f) continue; + if (!out.empty()) out += " \xc2\xb7 "; + out += noteName(notes[i]); + } + return out; +} + inline Text body(bool midi, float seconds, const std::string& knobText) { if (!midi) return {knobText, ""}; return {format("%.1f ms", seconds * 1000.0), "SNAP"}; @@ -42,9 +62,4 @@ inline std::string echoTime(bool warp, float seconds, const std::string& knobTex return warp ? format("%.2f s", seconds) : knobText; } -/// Drive and Tone only shape the echo while PONG is on. -inline Text echoShaping(bool pong, const std::string& knobText) { - return {knobText, pong ? "" : "PONG OFF"}; -} - } // namespace drosera::readouts diff --git a/src/plugin/ui/ResponseArcs.h b/src/plugin/ui/ResponseArcs.h new file mode 100644 index 0000000..7a41014 --- /dev/null +++ b/src/plugin/ui/ResponseArcs.h @@ -0,0 +1,35 @@ +#pragma once +// The ticks of the filter band arc and the Tone arc: each tick's length draws a filter's gain at its frequency. No JUCE, +// so it can be tested alone. + +#include + +namespace drosera::arcs { + +constexpr int kBandTicks = 26, kToneTicks = 11; +constexpr float kBandStub = 3.0f, kBandSpan = 14.0f; // a cut tick shrinks to the stub and never vanishes +constexpr float kToneStub = 2.0f, kToneSpan = 9.0f; + +/// The loop's two 12 dB/octave filters together. +inline float filterGain(float hz, float highpassHz, float lowpassHz) { + return 1.0f / std::sqrt(1.0f + std::pow(highpassHz / hz, 4.0f)) / std::sqrt(1.0f + std::pow(hz / lowpassHz, 4.0f)); +} + +/// Log-spaced from 10 Hz to 18 kHz, the span of the two knobs. +inline float bandTickHz(int i) { return 10.0f * std::pow(18000.0f / 10.0f, (float) i / (float) (kBandTicks - 1)); } + +inline void bandArc(float highpassHz, float lowpassHz, float* lengths) { + for (int i = 0; i < kBandTicks; ++i) lengths[i] = kBandStub + kBandSpan * filterGain(bandTickHz(i), highpassHz, lowpassHz); +} + +/// Log-spaced from 100 Hz to 6.4 kHz, around Tone's 200 Hz to 3.2 kHz. +inline float toneTickHz(int i) { return 100.0f * std::pow(6400.0f / 100.0f, (float) i / (float) (kToneTicks - 1)); } + +inline void toneArc(float centreHz, float* lengths) { + for (int i = 0; i < kToneTicks; ++i) { + const float d = std::log2(toneTickHz(i) / centreHz); + lengths[i] = kToneStub + kToneSpan * std::exp(-0.9f * d * d); + } +} + +} // namespace drosera::arcs diff --git a/src/plugin/ui/SetupPanel.cpp b/src/plugin/ui/SetupPanel.cpp new file mode 100644 index 0000000..e279e14 --- /dev/null +++ b/src/plugin/ui/SetupPanel.cpp @@ -0,0 +1,75 @@ +#include "SetupPanel.h" +#include "DroseraLookAndFeel.h" +#include "Pill.h" +#include "Theme.h" + +namespace drosera { + +namespace { +struct KnobPlace { const char* id; const char* caption; int x; KnobGroup group; }; +struct TogglePlace { const char* id; const char* caption; const char* title; int x, y; KnobGroup group; }; + +// Centres in the window, from the mockup. +constexpr int kKnobY = 476, kCellWidth = 66, kCellHeight = 90; +const KnobPlace kKnobs[] = { + {"coupling", "Coupling", 140, KnobGroup::loop}, {"interval2", "Interval 2", 210, KnobGroup::loop}, + {"interval3", "Interval 3", 280, KnobGroup::loop}, {"volume", "Volume", 415, KnobGroup::echo}, + {"inputgain", "Input", 485, KnobGroup::echo}, +}; +const TogglePlace kToggles[] = { + {"poly", "POLY", "Polyphony", 175, 418, KnobGroup::loop}, {"polylock", "LOCK", "String Lock", 245, 418, KnobGroup::loop}, + {"midipitch", "PITCH", "MIDI Pitch", 705, 456, KnobGroup::echo}, {"midigate", "GATE", "MIDI Gate", 785, 456, KnobGroup::echo}, +}; +constexpr const char* kStringKnobs[] = {"coupling", "interval2", "interval3"}; + +float panelY(float windowY) { return windowY - (float) SetupPanel::kTop; } +} // namespace + +SetupPanel::SetupPanel(juce::AudioProcessorValueTreeState& apvts) { + for (const auto& place : kKnobs) { + auto* parameter = apvts.getParameter(place.id); + jassert(parameter != nullptr); + knobs_[place.id] = std::make_unique(*parameter, place.caption, KnobSize::small, place.group); + addAndMakeVisible(*knobs_[place.id]); + } + for (const auto& place : kToggles) { + auto toggle = std::make_unique(); + toggle->button.setButtonText(place.caption); + toggle->button.setTitle(place.title); + toggle->button.setClickingTogglesState(true); + toggle->button.getProperties().set(DroseraLookAndFeel::kGroup, place.group == KnobGroup::loop ? "loop" : "echo"); + auto* parameter = apvts.getParameter(place.id); + jassert(parameter != nullptr); + toggle->attachment = std::make_unique(*parameter, toggle->button, nullptr); + addAndMakeVisible(toggle->button); + toggles_[place.id] = std::move(toggle); + } +} + +juce::Button* SetupPanel::toggle(const juce::String& paramId) { + const auto found = toggles_.find(paramId); + return found != toggles_.end() ? &found->second->button : nullptr; +} + +void SetupPanel::refresh(bool polyOn) { + for (const char* id : kStringKnobs) knobs_[id]->setDimmed(!polyOn); +} + +void SetupPanel::paint(juce::Graphics& g) { + g.setGradientFill(juce::ColourGradient(juce::Colour(0xff1b1315), 0.0f, 0.0f, juce::Colour(0xff140e10), 0.0f, (float) getHeight(), false)); + g.fillAll(); + g.setColour(theme::hairline()); + g.drawHorizontalLine(0, 0.0f, (float) getWidth()); + drawCartouche(g, "STRINGS", 100.0f, 320.0f, panelY(392.0f)); + drawCartouche(g, "OUTPUT", 375.0f, 525.0f, panelY(392.0f)); + drawCartouche(g, "MIDI", 660.0f, 830.0f, panelY(392.0f)); +} + +void SetupPanel::resized() { + const int top = kKnobY - kTop - LabeledKnob::radius(KnobSize::small) - LabeledKnob::margin(KnobSize::small); + for (const auto& place : kKnobs) knobs_[place.id]->setBounds(place.x - kCellWidth / 2, top, kCellWidth, kCellHeight); + for (const auto& place : kToggles) + toggles_[place.id]->button.setBounds(juce::Rectangle(56, 22).withCentre({place.x, place.y - kTop})); +} + +} // namespace drosera diff --git a/src/plugin/ui/SetupPanel.h b/src/plugin/ui/SetupPanel.h new file mode 100644 index 0000000..80ce989 --- /dev/null +++ b/src/plugin/ui/SetupPanel.h @@ -0,0 +1,40 @@ +#pragma once +// The setup panel, rising over the bottom of the window: Strings, Output and MIDI, which are set rather than played. + +#include +#include +#include +#include "LabeledKnob.h" + +namespace drosera { + +class SetupPanel final : public juce::Component { +public: + /// The panel's top in the window. Its children are laid out from the mockup's window coordinates. + static constexpr int kTop = 368; + + explicit SetupPanel(juce::AudioProcessorValueTreeState&); + + const std::map>& knobs() const { return knobs_; } + /// POLY, LOCK, PITCH or GATE, or nullptr for another ID. + juce::Button* toggle(const juce::String& paramId); + + /// Message thread: dims Coupling and the intervals while POLY is off. + void refresh(bool polyOn); + + void paint(juce::Graphics&) override; + void resized() override; + +private: + struct Toggle { + juce::ToggleButton button; + std::unique_ptr attachment; + }; + + std::map> knobs_; + std::map> toggles_; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(SetupPanel) +}; + +} // namespace drosera diff --git a/src/plugin/ui/SpectrumBands.h b/src/plugin/ui/SpectrumBands.h index a19455c..f03ecfc 100644 --- a/src/plugin/ui/SpectrumBands.h +++ b/src/plugin/ui/SpectrumBands.h @@ -7,13 +7,20 @@ namespace drosera { constexpr int kNumBands = 54; -constexpr float kFloorDb = -72.0f; +constexpr float kFloorDb = -54.0f; // lower, the loop's noise and far harmonics kept every tentacle half open /// Log-spaced from 30 Hz to 16 kHz. inline float bandCentreHz(int band) { return 30.0f * std::pow(16000.0f / 30.0f, (float) band / (float) (kNumBands - 1)); } +/// The band nearest a note's fundamental, or -1 when it falls outside the bands. +inline int bandOfNote(float midiNote) { + const float hz = 440.0f * std::pow(2.0f, (midiNote - 69.0f) / 12.0f); + if (hz < 30.0f || hz > 16000.0f) return -1; + return (int) std::lround(std::log(hz / 30.0f) / std::log(16000.0f / 30.0f) * (float) (kNumBands - 1)); +} + /// magnitudes: unnormalised |X[k]| of a Hann-windowed FFT, k = 0..fftSize/2. A full-scale sine gives 0 dBFS. A band with /// no bin inside it, as low bands are narrower than a bin, takes the nearest bin. Bands above Nyquist stay at rest. inline void computeBands(const float* magnitudes, int fftSize, double sampleRate, float* outDb) { diff --git a/src/plugin/ui/TabPanel.cpp b/src/plugin/ui/TabPanel.cpp deleted file mode 100644 index 448b80f..0000000 --- a/src/plugin/ui/TabPanel.cpp +++ /dev/null @@ -1,143 +0,0 @@ -#include "TabPanel.h" -#include "DroseraLookAndFeel.h" -#include "Theme.h" - -namespace drosera { - -namespace { -// In the panel's coordinates. The panel starts 306 px down the window. -constexpr int kStripTop = 8, kStripHeight = 22, kLineY = 32; -constexpr int kTabStep = 101, kTabWidth = 91, kMargin = 26; -constexpr int kCellTop = 48, kCellWidth = 92, kCellHeight = 92, kCellStep = 112; -constexpr int kToggleWidth = 78, kToggleHeight = 22; - -juce::Label& style(juce::Label& label, const juce::Font& font, juce::Colour colour) { - label.setFont(font); - label.setColour(juce::Label::textColourId, colour); - label.setJustificationType(juce::Justification::centred); - label.setInterceptsMouseClicks(false, false); - label.setBorderSize({}); - return label; -} - -const std::map& captions() { - static const std::map names { - {"lpf", "Lowpass"}, {"hpf", "Highpass"}, {"revmix", "Mix"}, {"revdecay", "Decay"}, {"echosend", "Send"}, - {"echotime", "Time"}, {"echofb", "Feedback"}, {"volume", "Volume"}, {"inputgain", "Input"}, - {"echodrive", "Drive"}, {"echotone", "Tone"}, {"echowidth", "Width"}, {"echolevel", "Level"}, - {"echohalve", "WARP"}, {"echopong", "PONG"}, {"midipitch", "PITCH"}, {"midigate", "GATE"}, - {"coupling", "Coupling"}, {"interval2", "Interval 2"}, {"interval3", "Interval 3"}, {"poly", "POLY"}, {"polylock", "LOCK"}, - }; - return names; -} -} // namespace - -LabeledKnob::LabeledKnob(juce::RangedAudioParameter& parameter, const juce::String& caption, bool mainControl) - : parameter_(parameter), attachment_(parameter, slider_, nullptr) { - slider_.setSliderStyle(juce::Slider::RotaryHorizontalVerticalDrag); - slider_.setRotaryParameters(juce::degreesToRadians(225.0f), juce::degreesToRadians(495.0f), true); - slider_.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); - slider_.setTitle(caption); - if (mainControl) slider_.getProperties().set(DroseraLookAndFeel::kMainControl, true); - addAndMakeVisible(slider_); - - caption_.setText(caption, juce::dontSendNotification); - addAndMakeVisible(style(caption_, theme::labelFont(), theme::text())); - addAndMakeVisible(style(value_, theme::valueFont(), theme::text().withAlpha(0.8f))); - addAndMakeVisible(style(tag_, theme::tagFont(), theme::dew())); -} - -void LabeledKnob::show(const juce::String& value, const juce::String& tag) { - value_.setText(value, juce::dontSendNotification); - tag_.setText(tag, juce::dontSendNotification); - slider_.setAlpha(tag.isEmpty() ? 1.0f : 0.45f); -} - -void LabeledKnob::resized() { - auto r = getLocalBounds(); - tag_.setBounds(r.removeFromBottom(13)); - value_.setBounds(r.removeFromBottom(14)); - caption_.setBounds(r.removeFromBottom(16)); - slider_.setBounds(r.withSizeKeepingCentre(juce::jmin(r.getWidth(), r.getHeight()), juce::jmin(r.getWidth(), r.getHeight()))); -} - -TabPanel::TabPanel(juce::AudioProcessorValueTreeState& apvts) - : pages_ {{0, "FILTERS", {"lpf", "hpf"}, {}}, - {1, "REVERB", {"revmix", "revdecay"}, {}}, - {2, "ECHO", {"echosend", "echotime", "echofb"}, {"echohalve"}}, - {6, "PONG", {"echodrive", "echotone", "echowidth", "echolevel"}, {"echopong"}}, - {3, "OUTPUT", {"volume", "inputgain"}, {}}, - {4, "MIDI", {}, {"midipitch", "midigate"}}, - {5, "STRINGS", {"coupling", "interval2", "interval3"}, {"poly", "polylock"}}} { - for (int i = 0; i < kNumTabs; ++i) { - auto& page = pages_[(size_t) i]; - auto& button = tabButtons_[i]; - button.setButtonText(page.title); - button.onClick = [this, id = page.id] { if (onTabClicked) onTabClicked(id); }; - addAndMakeVisible(button); - - for (const auto& id : page.knobs) { - auto* parameter = apvts.getParameter(id); - jassert(parameter != nullptr); - knobs_[id] = std::make_unique(*parameter, captions().at(id), false); - addChildComponent(*knobs_[id]); - } - for (const auto& id : page.toggles) { - auto toggle = std::make_unique(); - toggle->button.setButtonText(captions().at(id)); - toggle->button.setClickingTogglesState(true); - auto* parameter = apvts.getParameter(id); - jassert(parameter != nullptr); - toggle->attachment = std::make_unique(*parameter, toggle->button, nullptr); - addChildComponent(toggle->button); - toggles_[id] = std::move(toggle); - } - } - setTab(0); -} - -int TabPanel::place(int id) const { - for (int i = 0; i < kNumTabs; ++i) - if (pages_[(size_t) i].id == id) return i; - return 0; -} - -void TabPanel::setTab(int id) { - tab_ = pages_[(size_t) place(id)].id; // a damaged project opens the first tab - for (int i = 0; i < kNumTabs; ++i) { - const bool open = pages_[(size_t) i].id == tab_; - tabButtons_[i].setToggleState(open, juce::dontSendNotification); - for (const auto& paramId : pages_[(size_t) i].knobs) knobs_[paramId]->setVisible(open); - for (const auto& paramId : pages_[(size_t) i].toggles) toggles_[paramId]->button.setVisible(open); - } - repaint(); -} - -void TabPanel::paint(juce::Graphics& g) { - g.setColour(theme::track()); - g.fillRect(kMargin, kLineY, getWidth() - 2 * kMargin, 1); - g.setColour(theme::dew()); - g.fillRect(kMargin + place(tab_) * kTabStep, kLineY - 2, kTabWidth, 3); -} - -void TabPanel::resized() { - for (int i = 0; i < kNumTabs; ++i) - tabButtons_[i].setBounds(kMargin + i * kTabStep, kStripTop, kTabWidth, kStripHeight); - - for (const auto& page : pages_) { - const int count = (int) (page.knobs.size() + page.toggles.size()); - const int step = count > 1 ? juce::jmin(kCellStep, (getWidth() - 2 * kMargin - kCellWidth) / (count - 1)) : kCellStep; - int x = (getWidth() - (count - 1) * step) / 2; - for (const auto& id : page.knobs) { - knobs_[id]->setBounds(x - kCellWidth / 2, kCellTop, kCellWidth, kCellHeight); - x += step; - } - const int top = kCellTop + (kCellHeight - kToggleHeight) / 2 - 8; - for (const auto& id : page.toggles) { - toggles_[id]->button.setBounds(x - kToggleWidth / 2, top, kToggleWidth, kToggleHeight); - x += step; - } - } -} - -} // namespace drosera diff --git a/src/plugin/ui/TabPanel.h b/src/plugin/ui/TabPanel.h deleted file mode 100644 index 57ef1e3..0000000 --- a/src/plugin/ui/TabPanel.h +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once -// A knob with its name and readout, and the tab strip with the visible tab's controls. - -#include -#include -#include -#include - -namespace drosera { - -class LabeledKnob final : public juce::Component { -public: - LabeledKnob(juce::RangedAudioParameter&, const juce::String& caption, bool mainControl); - - juce::Slider& slider() { return slider_; } - juce::RangedAudioParameter& parameter() { return parameter_; } - /// Dims the knob while what sounds is not its position. It stays usable: moving it can take control back. - void show(const juce::String& value, const juce::String& tag); - juce::String readout() const { return value_.getText(); } - juce::String tag() const { return tag_.getText(); } - - void resized() override; - -private: - juce::RangedAudioParameter& parameter_; - juce::Slider slider_; - juce::Label caption_, value_, tag_; - juce::SliderParameterAttachment attachment_; -}; - -/// Tabs are named by an ID that projects save, not by their place in the strip: PONG has ID 6 though it follows ECHO, -/// so a project saved on OUTPUT, ID 3, still opens it. -class TabPanel final : public juce::Component { -public: - static constexpr int kNumTabs = 7; - - explicit TabPanel(juce::AudioProcessorValueTreeState&); - - void setTab(int id); - int tab() const { return tab_; } - std::function onTabClicked; - juce::Rectangle tabBounds(int id) const { return tabButtons_[place(id)].getBounds(); } - juce::Rectangle toggleBounds(const juce::String& paramId) const { return toggles_.at(paramId)->button.getBounds(); } - - /// The knobs in every tab, by parameter ID. - const std::map>& knobs() const { return knobs_; } - - void paint(juce::Graphics&) override; - void resized() override; - -private: - struct Page { - int id; - juce::String title; - std::vector knobs, toggles; - }; - - /// The tab's place in the strip, or 0 for an ID no tab has. - int place(int id) const; - struct Toggle { - juce::ToggleButton button; - std::unique_ptr attachment; - }; - - const std::vector pages_; - int tab_ = 0; - std::map> knobs_; - std::map> toggles_; - juce::TextButton tabButtons_[kNumTabs]; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(TabPanel) -}; - -} // namespace drosera diff --git a/src/plugin/ui/Theme.h b/src/plugin/ui/Theme.h index 024e30b..aa029ab 100644 --- a/src/plugin/ui/Theme.h +++ b/src/plugin/ui/Theme.h @@ -1,24 +1,46 @@ #pragma once -// Rugiada's colours and fonts. +// The interface's colours and fonts. Red marks what sits inside the feedback loop, amber what colours its output. #include +#include namespace drosera::theme { -inline juce::Colour background() { return juce::Colour(0xff120a10); } -inline juce::Colour panel() { return juce::Colour(0xff1c0d15); } -inline juce::Colour stalk() { return juce::Colour(0xffa33244); } -inline juce::Colour dew() { return juce::Colour(0xffffc27a); } -inline juce::Colour knob() { return juce::Colour(0xffc98a6a); } -inline juce::Colour track() { return juce::Colour(0xff3a1a24); } -inline juce::Colour text() { return juce::Colour(0xffe9d6c8); } -inline juce::Colour textDim() { return juce::Colour(0xffa98f88); } -inline juce::Colour heart() { return juce::Colour(0xff4a1320); } - -inline juce::Font labelFont() { return juce::FontOptions(12.0f); } -inline juce::Font valueFont() { return juce::FontOptions(11.0f); } -inline juce::Font tagFont() { return juce::FontOptions(9.5f, juce::Font::bold); } -inline juce::Font headFont() { return juce::FontOptions(11.0f).withKerningFactor(0.2f); } -inline juce::Font titleFont() { return juce::FontOptions("Georgia", 22.0f, juce::Font::plain).withKerningFactor(0.32f); } +inline juce::Colour backgroundCentre() { return juce::Colour(0xff1e1416); } +inline juce::Colour backgroundEdge() { return juce::Colour(0xff0f0a0b); } +inline juce::Colour panel() { return juce::Colour(0xff1b1416); } // the setup panel and menus +inline juce::Colour loop() { return juce::Colour(0xffd0465c); } +inline juce::Colour loopDeep() { return juce::Colour(0xffa33244); } // tentacle bases +inline juce::Colour echo() { return juce::Colour(0xffffc27a); } +inline juce::Colour track() { return juce::Colour(0xff2a1f21); } +inline juce::Colour text() { return juce::Colour(0xffeadacd); } +inline juce::Colour textDim() { return juce::Colour(0xff9d8a83); } +inline juce::Colour hairline() { return juce::Colour(0xff33272a); } +inline juce::Colour cartouche() { return juce::Colour(0xff3d2f31); } + +namespace detail { +inline juce::Typeface::Ptr load(const char* data, int size) { return juce::Typeface::createSystemTypefaceFor(data, (size_t) size); } +inline juce::Typeface::Ptr cormorant() { static auto t = load(BinaryData::CormorantGaramondMedium_otf, BinaryData::CormorantGaramondMedium_otfSize); return t; } +inline juce::Typeface::Ptr interRegular() { static auto t = load(BinaryData::InterRegular_ttf, BinaryData::InterRegular_ttfSize); return t; } +inline juce::Typeface::Ptr interMedium() { static auto t = load(BinaryData::InterMedium_ttf, BinaryData::InterMedium_ttfSize); return t; } + +/// A missing typeface falls back to JUCE's default sans-serif at the same size, so the layout holds. +inline juce::Font font(const juce::Typeface::Ptr& typeface, float height, float spacingPx = 0.0f) { + auto options = typeface != nullptr ? juce::FontOptions(typeface).withHeight(height) : juce::FontOptions(height); + return juce::Font(options.withKerningFactor(spacingPx / height)); +} +} // namespace detail + +inline bool fontsEmbedded() { + return detail::cormorant() != nullptr && detail::interRegular() != nullptr && detail::interMedium() != nullptr; +} + +inline juce::Font titleFont() { return detail::font(detail::cormorant(), 26.0f, 9.0f); } +inline juce::Font largeLabelFont() { return detail::font(detail::interMedium(), 14.0f); } +inline juce::Font labelFont() { return detail::font(detail::interMedium(), 11.0f); } +inline juce::Font largeValueFont() { return detail::font(detail::interRegular(), 12.0f); } +inline juce::Font valueFont() { return detail::font(detail::interRegular(), 10.0f); } +inline juce::Font captionFont() { return detail::font(detail::interMedium(), 9.5f, 3.0f); } +inline juce::Font tagFont() { return detail::font(detail::interMedium(), 9.0f, 1.0f); } } // namespace drosera::theme diff --git a/tests/editor_snapshot.cpp b/tests/editor_snapshot.cpp index c49d01e..eb81399 100644 --- a/tests/editor_snapshot.cpp +++ b/tests/editor_snapshot.cpp @@ -1,5 +1,6 @@ -// Renders the plugin editor to a PNG: editor_snapshot [paramId=normalizedValue | program=N | note=N | tab=N | sound=1 ...] -// tab opens a tab. sound=1 renders a second of audio with the window open, so the plant shows real sound. +// Renders the plugin editor to a PNG: editor_snapshot [paramId=normalizedValue | program=N | note=N | sound=1 | setup=1 | +// scale=N ...] +// setup=1 opens the setup panel, scale=N sizes the window at N times 900×560. sound=1 renders a second of audio with the window open, so the plant shows real sound. #include #include #include "PluginEditor.h" @@ -10,16 +11,17 @@ int main(int argc, char** argv) { if (argc < 2) { std::printf("usage: editor_snapshot [id=value ...]\n"); return 2; } juce::ScopedJuceInitialiser_GUI init; std::unique_ptr proc(createPluginFilter()); - int tab = -1; - bool sound = false, prepared = false; + bool sound = false, prepared = false, setup = false; + float scale = 1.0f; auto prepare = [&] { if (!prepared) { proc->prepareToPlay(48000.0, 512); prepared = true; } }; for (int i = 2; i < argc; ++i) { juce::String arg(argv[i]); auto id = arg.upToFirstOccurrenceOf("=", false, false); auto v = arg.fromFirstOccurrenceOf("=", false, false).getFloatValue(); if (id == "program") { proc->setCurrentProgram((int) v); continue; } - if (id == "tab") { tab = (int) v; continue; } if (id == "sound") { sound = v > 0.5f; continue; } + if (id == "setup") { setup = v > 0.5f; continue; } + if (id == "scale") { scale = v; continue; } if (id == "note") { // play a note through processBlock before drawing prepare(); juce::AudioBuffer buf(2, 512); @@ -35,7 +37,8 @@ int main(int argc, char** argv) { std::unique_ptr ed(proc->createEditor()); auto* editor = dynamic_cast(ed.get()); ed->setVisible(true); - if (tab >= 0) editor->setTab(tab); + ed->setSize(juce::roundToInt(900.0f * scale), juce::roundToInt(560.0f * scale)); + editor->setSetupOpen(setup); if (prepared || sound) { // let the values settle, with the window open prepare(); juce::AudioBuffer buf(2, 512); @@ -52,6 +55,6 @@ int main(int argc, char** argv) { out.deleteFile(); juce::FileOutputStream fos(out); juce::PNGImageFormat().writeImageToStream(img, fos); - std::printf("editor %dx%d tab %d -> %s\n", ed->getWidth(), ed->getHeight(), editor->tab(), argv[1]); + std::printf("editor %dx%d -> %s\n", ed->getWidth(), ed->getHeight(), argv[1]); return 0; } diff --git a/tests/ui_test.cpp b/tests/ui_test.cpp index 470c3a2..1854c10 100644 --- a/tests/ui_test.cpp +++ b/tests/ui_test.cpp @@ -13,8 +13,11 @@ #include "PluginEditor.h" #include "pitch_estimator.h" #include "SpectrumTap.h" +#include "ui/EchoPulse.h" #include "ui/PlantView.h" #include "ui/Readouts.h" +#include "ui/Theme.h" +#include "ui/ResponseArcs.h" #include "ui/SpectrumBands.h" juce::AudioProcessor* JUCE_CALLTYPE createPluginFilter(); @@ -190,12 +193,54 @@ static void readoutTests() { check(is(body(false, 0.0971f, "30.0 ms"), "30.0 ms", ""), "body from the knob"); check(echoTime(true, 0.359f, "x") == "0.36 s", "echo time with WARP"); check(echoTime(false, 0.359f, "1.29 s") == "1.29 s", "echo time from the knob"); - check(is(echoShaping(true, "+6.0 dB"), "+6.0 dB", ""), "echo shaping with PONG on"); - check(is(echoShaping(false, "+6.0 dB"), "+6.0 dB", "PONG OFF"), "echo shaping with PONG off"); check(is(interval(false, 60.0f, "+7 st"), "+7 st", ""), "interval from the knob"); check(is(interval(true, 60.0f, "+7 st"), "C4", "MIDI"), "interval with a MIDI note"); check(is(interval(true, -1.0f, "+7 st"), "-", "MIDI"), "interval with no note"); check(noteName(40.0f) == "E2" && noteName(60.0f) == "C4" && noteName(69.0f) == "A4", "note names"); + check(is(playedInterval(true, 72.0f, 84.0f, 24, "+24 st"), "+12 st", "LIMIT"), "interval held at the note limit"); + check(is(playedInterval(true, 60.0f, 67.0f, 7, "+7 st"), "+7 st", ""), "interval played as set"); + check(is(playedInterval(false, 72.0f, 84.0f, 24, "+24 st"), "+24 st", ""), "interval with POLY off"); + check(is(playedInterval(true, 72.0f, -1.0f, 24, "+24 st"), "+24 st", ""), "interval of a silent voice"); + const float triad[] = {48.0f, 55.0f, 60.0f}, gap[] = {48.0f, -1.0f, 60.0f}; + check(chord(triad, 3) == "C3 \xc2\xb7 G3 \xc2\xb7 C4" && chord(gap, 3) == "C3 \xc2\xb7 C4", "chord names the sounding voices"); + int nearest440 = 0; + for (int i = 0; i < kNumBands; ++i) + if (std::abs(std::log2(bandCentreHz(i) / 440.0f)) < std::abs(std::log2(bandCentreHz(nearest440) / 440.0f))) nearest440 = i; + check(bandOfNote(69.0f) == nearest440 && bandOfNote(16.0f) == -1, "bandOfNote"); +} + +static void themeTests() { + check(theme::fontsEmbedded(), "fontsAreEmbedded"); + check(theme::titleFont().getTypefaceName() == "Cormorant Garamond", "titleUsesCormorant"); + check(theme::labelFont().getTypefaceName() == "Inter", "labelsUseInter"); +} + +static void arcTests() { + using namespace arcs; + float band[kBandTicks], tone[kToneTicks]; + bandArc(1000.0f, 18000.0f, band); + bool lowCut = true; + int nearFive = 0; + for (int i = 0; i < kBandTicks; ++i) { + if (bandTickHz(i) < 300.0f) lowCut = lowCut && band[i] < kBandStub + 0.1f * kBandSpan; + if (std::abs(std::log2(bandTickHz(i) / 5000.0f)) < std::abs(std::log2(bandTickHz(nearFive) / 5000.0f))) nearFive = i; + } + check(lowCut && band[nearFive] > kBandStub + 0.9f * kBandSpan, "bandArcFollowsFilters"); + + bandArc(4000.0f, 100.0f, band); + check(std::all_of(band, band + kBandTicks, [](float l) { return l >= kBandStub; }), "bandArcNeverVanishes"); + + auto peak = [&] { return toneTickHz((int) (std::max_element(tone, tone + kToneTicks) - tone)); }; + toneArc(800.0f, tone); + const float step = std::log2(toneTickHz(1) / toneTickHz(0)); + const float at800 = peak(); + toneArc(3200.0f, tone); + check(std::abs(std::log2(at800 / 800.0f)) <= step && peak() > 2000.0f, "toneArcPeaksAtTone"); + + using pulse::Lit; + check(pulse::echoPulse(0.01, 0.5, false, true) == Lit::single && pulse::echoPulse(0.2, 0.5, false, true) == Lit::none + && pulse::echoPulse(0.51, 0.5, true, true) == Lit::right && pulse::echoPulse(1.01, 0.5, true, true) == Lit::left + && pulse::echoPulse(0.01, 0.5, false, false) == Lit::none, "pulseFollowsEchoTime"); } static void plantTests() { @@ -248,6 +293,28 @@ static void plantTests() { check(swing < 1.0, "plantStillWithLongBlocks"); } + { // Every band has its tentacle, and the strings' bands are marked. + // Bands run in order around the leaf, lowest at the top, so a change of pitch moves the light around the plant. + bool inOrder = true; + for (int j = 0; j < kNumBands; ++j) inOrder = inOrder && PlantView::tentacleBand(j) == j; + check(inOrder, "tentacleShowsItsOwnBand"); + + // A sounding tentacle must stand out from a silent one: on average at least five times as far from its root. + PlantView sized; + sized.setBounds(0, 0, 520, 340); + double ratio = 0.0; + for (int j = 0; j < kNumBands; ++j) ratio += sized.reach(j, 1.0f) / sized.reach(j, 0.0f); + ratio /= kNumBands; + std::printf(" sounding over silent tentacle reach: %.1f\n", ratio); + check(ratio >= 5.0, "soundStandsOutFromRest"); + PlantView loud; + loud.setBounds(0, 0, 520, 340); + check(loud.getLocalBounds().toFloat().contains(loud.reachAt(1.0f)), "loudTentaclesStayInside"); + PlantView marked; + marked.setVoiceBands({10, -1, 30}); + check(marked.isMarked(10) && marked.isMarked(30) && !marked.isMarked(20), "plantMarksVoiceBands"); + } + PlantView unprepared; for (int i = 0; i < 30; ++i) { pushSine(); unprepared.update(tap, 0.0, dt); } check(unprepared.level(30) == kFloorDb, "plantRestsWithNoSampleRate"); @@ -268,6 +335,13 @@ static float parameter(juce::AudioProcessor& p, const char* id) { return -1.0f; } +// Visible all the way up to the editor: isShowing() also needs a window on the desktop, which tests do not have. +static bool shownIn(const juce::Component& editor, const juce::Component* c) { + for (; c != nullptr && c != &editor; c = c->getParentComponent()) + if (!c->isVisible()) return false; + return c == &editor; +} + static std::unique_ptr openEditor(juce::AudioProcessor& p) { return std::unique_ptr(dynamic_cast(p.createEditor())); } @@ -284,35 +358,78 @@ static void render(juce::AudioProcessor& p, double seconds, juce::MidiBuffer* mi static void editorTests() { constexpr float dt = 1.0f / 30.0f; - { // The open tab lives in the plugin's state, and a bad or missing value opens Filters. + { // Projects saved with tabs still load: the open tab they saved is ignored. + std::unique_ptr first(createPluginFilter()); + setParameter(*first, "lpf", 0.33f); + juce::MemoryBlock state; + first->getStateInformation(state); + auto xml = juce::AudioProcessor::getXmlFromBinary(state.getData(), (int) state.getSize()); + xml->setAttribute("uiTab", 3); + juce::MemoryBlock withTab; + juce::AudioProcessor::copyXmlToBinary(*xml, withTab); + std::unique_ptr second(createPluginFilter()); + second->setStateInformation(withTab.getData(), (int) withTab.getSize()); + auto editor = openEditor(*second); + check(editor->getWidth() == 900 && std::abs(parameter(*second, "lpf") - 0.33f) < 0.001f, "savedTabIsIgnored"); + } + + { // The window scales from 75 % to 150 % with its proportions, and the scale lives in the plugin's state. std::unique_ptr first(createPluginFilter()); + setParameter(*first, "lpf", 0.33f); juce::MemoryBlock state; { auto editor = openEditor(*first); - editor->setTab(3); + editor->setSize(1350, 840); first->getStateInformation(state); } std::unique_ptr second(createPluginFilter()); second->setStateInformation(state.getData(), (int) state.getSize()); { auto reopened = openEditor(*second); - check(reopened->tab() == 3 && reopened->slider("volume")->getParentComponent()->isVisible(), "tabPersists"); // 3 is OUTPUT, as before PONG + check(reopened->getWidth() == 1350 && reopened->getHeight() == 840 && std::abs(reopened->scale() - 1.5f) < 0.001f, "scalePersists"); } - setParameter(*first, "lpf", 0.33f); - first->getStateInformation(state); auto xml = juce::AudioProcessor::getXmlFromBinary(state.getData(), (int) state.getSize()); - xml->setAttribute("uiTab", 99); + xml->setAttribute("uiScale", 9.0); juce::MemoryBlock bad; juce::AudioProcessor::copyXmlToBinary(*xml, bad); - xml->removeAttribute("uiTab"); + xml->removeAttribute("uiScale"); juce::MemoryBlock missing; juce::AudioProcessor::copyXmlToBinary(*xml, missing); std::unique_ptr third(createPluginFilter()), fourth(createPluginFilter()); third->setStateInformation(bad.getData(), (int) bad.getSize()); fourth->setStateInformation(missing.getData(), (int) missing.getSize()); - check(openEditor(*third)->tab() == 0 && std::abs(parameter(*third, "lpf") - 0.33f) < 0.001f, "tabSurvivesBadState"); - check(openEditor(*fourth)->tab() == 0 && std::abs(parameter(*fourth, "lpf") - 0.33f) < 0.001f, "tabSurvivesMissingState"); + check(openEditor(*third)->getWidth() == 900 && std::abs(parameter(*third, "lpf") - 0.33f) < 0.001f, "scaleSurvivesBadState"); + check(openEditor(*fourth)->getWidth() == 900 && std::abs(parameter(*fourth, "lpf") - 0.33f) < 0.001f, "scaleSurvivesMissingState"); + + auto editor = openEditor(*fourth); + editor->setBoundsConstrained({0, 0, 1000, 1000}); + const bool proportional = std::abs(editor->getHeight() - editor->getWidth() * 560.0 / 900.0) <= 1.0; + editor->setBoundsConstrained({0, 0, 200, 100}); + check(proportional && editor->getWidth() == 675 && editor->getHeight() == 420, "windowKeepsItsProportions"); + + // A host that sets a wider size directly, past the constrainer, must not cut the row off. + editor->setSize(1000, 560); + auto* echoCell = editor->slider("echofb")->getParentComponent(); + const auto inWindow = editor->getLocalArea(echoCell, echoCell->getLocalBounds()); + const float saved = dynamic_cast(fourth.get())->uiScale(); + check(inWindow.getBottom() <= 560 && inWindow.getRight() <= 1000 && saved >= 0.75f && saved <= 1.5f, "widerHostSizeKeepsTheRow"); + } + + { // A host may restore state off the message thread while the window is resized. + std::unique_ptr p(createPluginFilter()); + juce::MemoryBlock state; + p->getStateInformation(state); + auto editor = openEditor(*p); + std::atomic done { false }; + std::thread host([&] { for (int i = 0; i < 2000 && !done; ++i) p->setStateInformation(state.getData(), (int) state.getSize()); }); + for (int i = 0; i < 4000; ++i) { + editor->setSize(675 + (i % 8) * 96, juce::roundToInt((675 + (i % 8) * 96) * 560.0 / 900.0)); + editor->refreshDisplay(0.0f); + } + done = true; + host.join(); + check(true, "scaleSafeOffThread"); } { // A host may restore state off the message thread while the window reads the open tab. @@ -328,23 +445,42 @@ static void editorTests() { check(true, "stateRestoredOffThreadWithWindowOpen"); } - { // Only the open tab's controls show; the main ones always do. + { // One page: everything played live always shows, the rest is one click away in the setup panel. std::unique_ptr p(createPluginFilter()); auto editor = openEditor(*p); - check(editor->getWidth() == 760 && editor->getHeight() == 500, "windowSize"); + check(editor->getWidth() == 900 && editor->getHeight() == 560, "windowSize"); editor->setVisible(true); - editor->setTab(2); - auto shown = [&](const char* id) { return editor->slider(id)->getParentComponent()->isVisible(); }; - check(shown("echosend") && !shown("lpf") && shown("freq") && shown("body") && shown("fbgain"), "tabsShowTheirControls"); + + bool all = true; + for (const char* id : { "freq", "body", "fbgain", "hpf", "lpf", "revmix", "revdecay", "echodrive", "echotone", "echowidth", + "echolevel", "echosend", "echotime", "echofb", "echopong", "echohalve", "poly", "polylock", "coupling", + "interval2", "interval3", "volume", "inputgain", "midipitch", "midigate" }) + all = all && (editor->slider(id) != nullptr || editor->toggle(id) != nullptr); + check(all, "everyControlHasAWidget"); check(editor->slider("tapewow") == nullptr, "noTapeControls"); - // Changes made while a control is hidden show when it returns. - editor->setTab(1); - setParameter(*p, "lpf", 0.8f); - editor->setTab(0); - check(std::abs((float) editor->slider("lpf")->getValue() - 0.8f) < 0.001f, "hiddenValuesFollow"); - p->setCurrentProgram(2); - check(std::abs((float) editor->slider("lpf")->getValue() - parameter(*p, "lpf")) < 0.001f, "presetReachesHiddenControls"); + auto showing = [&](const char* id) { return shownIn(*editor, editor->slider(id)); }; + bool play = true; + for (const char* id : { "freq", "body", "fbgain", "hpf", "lpf", "revmix", "revdecay", "echosend", "echotime", "echofb", + "echodrive", "echotone", "echowidth", "echolevel" }) + play = play && showing(id); + setParameter(*p, "echopong", 1.0f); + editor->refreshDisplay(0.0f); + play = play && showing("echodrive") && showing("echolevel"); + check(play && !editor->setupOpen(), "playControlsAlwaysShow"); + + check(!showing("coupling") && !showing("volume"), "setupPanelStartsClosed"); + editor->setSetupOpen(true); + const bool opened = showing("coupling") && showing("volume") && shownIn(*editor, editor->toggle("midipitch")); + editor->setSetupOpen(false); + check(opened && !showing("coupling") && !shownIn(*editor, editor->toggle("midipitch")), "setupPanelOpensAndCloses"); + + // Changes made while the panel is closed show when it opens. + setParameter(*p, "coupling", 0.7f); + p->setCurrentProgram(10); + editor->setSetupOpen(true); + check(std::abs((float) editor->slider("coupling")->getValue() - parameter(*p, "coupling")) < 0.001f, "changesReachTheClosedPanel"); + check(std::abs((float) editor->slider("lpf")->getValue() - parameter(*p, "lpf")) < 0.001f, "presetReachesEveryControl"); } { // MIDI: the readouts say what sounds, and the dimmed knobs stay usable. @@ -707,51 +843,43 @@ static void editorTests() { check(openEditor(*p)->slider("fbgain")->getTitle() == "Feedback", "feedbackHasATitle"); } - { // STRINGS keeps ID 5 and PONG takes 6, though PONG is shown after ECHO: saved projects open the tab they were saved on. + { // PONG's knobs stay in place in CLASSIC, dimmed and still usable; the strings' knobs dim with POLY off. std::unique_ptr p(createPluginFilter()); - juce::MemoryBlock state; - { - auto editor = openEditor(*p); - editor->setVisible(true); - editor->setTab(5); - auto shown = [&](const char* id) { return editor->slider(id)->getParentComponent()->isVisible(); }; - check(shown("coupling") && shown("interval2") && shown("interval3") && !shown("lpf"), "stringsTabShowsItsControls"); - check(editor->tabBounds(5).getRight() <= 760 - 26, "tabsFitTheWindow"); - check(editor->tabBounds(2).getRight() < editor->tabBounds(6).getX() && editor->tabBounds(6).getRight() < editor->tabBounds(3).getX(), - "pongTabSitsAfterEcho"); - const auto knobBounds = [&](const char* id) { return editor->slider(id)->getParentComponent()->getBounds(); }; - check(knobBounds("interval2").getX() - knobBounds("coupling").getX() == 112, "otherTabsKeepTheirPlaces"); - - editor->setTab(2); - check(shown("echosend") && shown("echotime") && shown("echofb") && !shown("echodrive"), "echoTabShowsItsControls"); - editor->setTab(6); - check(shown("echodrive") && shown("echotone") && shown("echowidth") && shown("echolevel") && !shown("echosend"), "pongTabShowsItsControls"); - std::vector> cells; - for (const char* id : { "echodrive", "echotone", "echowidth", "echolevel" }) cells.push_back(knobBounds(id)); - cells.push_back(editor->toggleBounds("echopong")); - bool fit = true; - for (size_t i = 0; i < cells.size(); ++i) { - fit = fit && !cells[i].isEmpty() && cells[i].getX() >= 26 && cells[i].getRight() <= 760 - 26; - for (size_t j = i + 1; j < cells.size(); ++j) fit = fit && !cells[i].intersects(cells[j]); - } - check(fit, "pongControlsFit"); + auto editor = openEditor(*p); + editor->setVisible(true); + auto cell = [&](const char* id) { return editor->slider(id)->getParentComponent(); }; + setParameter(*p, "echopong", 0.0f); + editor->refreshDisplay(0.0f); + const bool dimmed = cell("echodrive")->getAlpha() < 1.0f && cell("echolevel")->getAlpha() < 1.0f && shownIn(*editor, cell("echodrive")) + && cell("echosend")->getAlpha() == 1.0f; + editor->slider("echodrive")->setValue(0.8, juce::sendNotificationSync); + check(dimmed && std::abs(parameter(*p, "echodrive") - 0.8f) < 0.001f, "pongControlsDimInClassic"); + setParameter(*p, "echopong", 1.0f); + editor->refreshDisplay(0.0f); + check(cell("echodrive")->getAlpha() == 1.0f && cell("echotone")->getAlpha() == 1.0f, "pongControlsLightInPong"); - setParameter(*p, "echopong", 0.0f); - editor->refreshDisplay(0.0f); - check(editor->tag("echodrive") == "PONG OFF" && editor->tag("echotone") == "PONG OFF" && editor->tag("echowidth") == "PONG OFF" - && editor->tag("echolevel") == "PONG OFF" && editor->slider("echolevel")->getAlpha() < 1.0f - && editor->tag("echofb").isEmpty(), "shapingTagWithPongOff"); - editor->slider("echodrive")->setValue(0.8, juce::sendNotificationSync); - check(std::abs(parameter(*p, "echodrive") - 0.8f) < 0.001f, "shapingStaysEditableWithPongOff"); - setParameter(*p, "echopong", 1.0f); - editor->refreshDisplay(0.0f); - check(editor->tag("echodrive").isEmpty() && editor->tag("echotone").isEmpty() && editor->tag("echowidth").isEmpty() - && editor->readout("echolevel") == "0.0 dB" && editor->slider("echotone")->getAlpha() == 1.0f, "shapingTagWithPongOn"); - p->getStateInformation(state); + setParameter(*p, "poly", 0.0f); + editor->setSetupOpen(true); + editor->refreshDisplay(0.0f); + const bool polyOff = cell("coupling")->getAlpha() < 1.0f && cell("volume")->getAlpha() == 1.0f; + setParameter(*p, "poly", 1.0f); + editor->refreshDisplay(0.0f); + check(polyOff && cell("coupling")->getAlpha() == 1.0f, "stringsDimWithPolyOff"); + editor->setSetupOpen(false); + + // The row's controls fit the window side by side, in the order of the spec. + std::vector> cells; + for (const char* id : { "hpf", "lpf", "revmix", "revdecay", "echodrive", "echotone", "echowidth", "echolevel", "echosend", "echotime", "echofb" }) + cells.push_back(editor->getLocalArea(cell(id), cell(id)->getLocalBounds())); + cells.push_back(editor->getLocalArea(editor->toggle("echopong"), editor->toggle("echopong")->getLocalBounds())); + cells.push_back(editor->getLocalArea(editor->toggle("echohalve"), editor->toggle("echohalve")->getLocalBounds())); + bool fit = true; + for (size_t i = 0; i < cells.size(); ++i) { + fit = fit && !cells[i].isEmpty() && cells[i].getX() >= 0 && cells[i].getRight() <= 900 && cells[i].getBottom() <= 560; + for (size_t j = i + 1; j < cells.size(); ++j) fit = fit && !cells[i].intersects(cells[j]); + if (i > 0 && i < 11) fit = fit && cells[i].getCentreX() > cells[i - 1].getCentreX(); } - std::unique_ptr second(createPluginFilter()); - second->setStateInformation(state.getData(), (int) state.getSize()); - check(openEditor(*second)->tab() == 6, "pongTabPersists"); + check(fit, "rowControlsFit"); } { // The interval knobs show the notes the voices play when MIDI Pitch controls them. @@ -771,6 +899,88 @@ static void editorTests() { check(editor->readout("interval3") == "-" && editor->tag("interval3") == "MIDI", "intervalWithoutANote"); } + { // With POLY from the knobs, an interval beyond the note range shows what the voice really plays. + std::unique_ptr p(createPluginFilter()); + p->prepareToPlay(48000.0, 512); + auto editor = openEditor(*p); + setParameter(*p, "poly", 1.0f); + setParameter(*p, "freq", 1.0f); + setParameter(*p, "interval2", 1.0f); + render(*p, 0.2); + editor->refreshDisplay(dt); + check(editor->readout("interval2") == "+12 st" && editor->tag("interval2") == "LIMIT", "intervalShowsWhatSounds"); + setParameter(*p, "freq", 0.3f); + setParameter(*p, "interval2", 31.0f / 48.0f); + render(*p, 0.2); + editor->refreshDisplay(dt); + check(editor->readout("freq").contains(juce::String::fromUTF8(" \xc2\xb7 ")), "frequencyNamesTheChord"); + } + + { // The header: arrows step through the presets and wrap, the menu lists them all, SETUP opens the panel. + std::unique_ptr p(createPluginFilter()); + auto editor = openEditor(*p); + auto& header = editor->header(); + p->setCurrentProgram(0); + header.previousButton().onClick(); + const bool wrapped = p->getCurrentProgram() == p->getNumPrograms() - 1; + header.nextButton().onClick(); + check(wrapped && p->getCurrentProgram() == 0, "presetArrowsCycle"); + + p->setCurrentProgram(2); + editor->refreshDisplay(0.0f); + const auto menu = header.presetMenu(); + int items = 0, ticked = -1; + for (juce::PopupMenu::MenuItemIterator it(menu); it.next();) { + if (it.getItem().isTicked) ticked = items; + ++items; + } + check(items == p->getNumPrograms() && ticked == 2, "presetMenuListsEveryPreset"); + + header.setupButton().onClick(); + const bool opened = editor->setupOpen(); + header.setupButton().onClick(); + check(opened && !editor->setupOpen(), "setupFieldOpensThePanel"); + } + + { // With MIDI Gate, a released voice falls silent, so it is neither named nor marked. + std::unique_ptr p(createPluginFilter()); + p->prepareToPlay(48000.0, 512); + auto editor = openEditor(*p); + for (const char* id : { "poly", "midipitch", "midigate" }) setParameter(*p, id, 1.0f); + juce::MidiBuffer chord, release; + for (int n : { 48, 55, 64 }) chord.addEvent(juce::MidiMessage::noteOn(1, n, (juce::uint8) 100), 0); + release.addEvent(juce::MidiMessage::noteOff(1, 55), 0); + render(*p, 0.1, &chord); + render(*p, 0.1, &release); + editor->refreshDisplay(dt); + check(editor->readout("freq") == juce::String::fromUTF8("C3 \xc2\xb7 E4") && !editor->plant().isMarked(bandOfNote(55.0f)) + && editor->plant().isMarked(bandOfNote(48.0f)), "gatedReleasedVoiceIsNotNamed"); + } + + { // A played note must stand out on the plant: bands far below it, its noise and distant harmonics, stay near rest. + std::unique_ptr p(createPluginFilter()); + p->prepareToPlay(48000.0, 800); + auto editor = openEditor(*p); + for (const char* id : { "midipitch", "midigate" }) setParameter(*p, id, 1.0f); + setParameter(*p, "fbgain", 63.0f / 72.0f); // +3 dB: the loop sings + juce::MidiBuffer note, none; + note.addEvent(juce::MidiMessage::noteOn(1, 48, (juce::uint8) 100), 0); + juce::AudioBuffer buffer(2, 800); + for (int b = 0; b < 90; ++b) { // 1.5 s, a display tick per block as at 60 Hz + buffer.clear(); + p->processBlock(buffer, b == 0 ? note : none); + editor->refreshDisplay(1.0f / 60.0f); + } + std::vector amounts; + for (int i = 0; i < kNumBands; ++i) amounts.push_back(bandAmount(editor->plant().level(i))); + const float peak = *std::max_element(amounts.begin(), amounts.end()); + std::nth_element(amounts.begin(), amounts.begin() + kNumBands / 2, amounts.end()); + const float median = amounts[(size_t) kNumBands / 2]; + const auto out = [&](float above) { return (int) std::count_if(amounts.begin(), amounts.end(), [above](float a) { return a > above; }); }; + std::printf(" played note: peak tentacle %.2f, median %.2f; out past 0.3: %d, past 0.5: %d of %d\n", peak, median, out(0.3f), out(0.5f), kNumBands); + check(peak > 0.6f && median < 0.1f, "playedNoteStandsOut"); + } + { // A preset chosen in the window reaches the host's own display. struct Listener final : juce::AudioProcessorListener { bool programChanged = false; @@ -891,9 +1101,11 @@ static void stateJumpTests() { int main() { juce::ScopedJuceInitialiser_GUI init; + themeTests(); tapTests(); bandTests(); readoutTests(); + arcTests(); plantTests(); editorTests(); stateTests();