diff --git a/README.md b/README.md
index 8ce48f0..3b29afc 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ body alone. Formats: **VST3** and **CLAP** (Windows, macOS, Linux).
[](https://github.com/affinedsp/suppressor/actions/workflows/ci.yml)
-
+
## What it does
@@ -37,13 +37,14 @@ biquads plus an envelope per channel).
| **Release** | Sets how quickly suppression returns after a note (2–30 ms). Increase it to preserve tails. |
| **Listen removed** | Auditions input minus processed audio; switch it off to hear the processed signal. This difference includes filter phase effects, not just noise. |
-The **IN** and **OUT** ladders show **peak dBFS**, each with a clip cell held for
-one second. The backlit meter shows **high-band gain reduction**, not
-whole-signal loudness loss; in multiband sessions it shows the deepest band
-reduction. While *Listen removed* is on, the output column is relabelled
-**REMOVED**. Bypass, silence, learning, and stopped audio are labelled
-explicitly, and stopped meters go dark (the meter lamp dims and the needle
-rests) instead of holding stale readings.
+The screen plots **high-band gain reduction** over the last six seconds, not
+whole-signal loudness loss, with the current value above the plot; in multiband
+sessions it shows the deepest band reduction. The **INPUT** and **OUTPUT** bars
+show **peak dBFS** with a readout, each with a clip cell held for one second.
+While *Listen removed* is on, the output bar is relabelled **REMOVED**. Bypass,
+silence, learning, and stopped audio are labelled explicitly; the plot breaks
+wherever there was no reading, and stopped bars go dark instead of holding
+stale readings.
Drag a knob to adjust; **Shift-drag** for fine control, **double-click / Return**
for exact entry in its readout, and **Alt/Option-click / Home** to reset.
diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp
index 9ecaa7d..b09d9d5 100644
--- a/Source/PluginEditor.cpp
+++ b/Source/PluginEditor.cpp
@@ -6,30 +6,261 @@ float db (float peak) { return juce::Decibels::gainToDecibels (peak, -100.0f); }
juce::String levelText (float peak) { return peak < 0.00001f ? "-inf" : juce::String (db (peak), 1); }
juce::String whole (double v) { return juce::String (juce::roundToInt (v)); }
-// Layout, in logical pixels. The signal runs left to right across the meter bridge.
-const juce::Rectangle statusArea { 548, 26, 214, 30 };
-const juce::Rectangle hostSettingsArea { 548, 60, 214, 16 };
-const juce::Rectangle meterArea { 218, 90, 364, 210 };
-const juce::Rectangle inputColumn { 136, 110, 24, 160 }, outputColumn { 640, 110, 24, 160 };
-const juce::Rectangle inputReadout { 116, 276, 64, 20 }, outputReadout { 620, 276, 64, 20 };
-const juce::Rectangle summaryArea { 250, 302, 300, 14 };
-const juce::Rectangle suppressionFrame { 40.0f, 330.0f, 560.0f, 168.0f };
-const juce::Rectangle monitorFrame { 612.0f, 330.0f, 148.0f, 168.0f };
-constexpr int knobAxis = 410;
-constexpr int knobCentres[] { 150, 320, 490 };
-const juce::Point listenCentre { 686, 414 };
+// Layout, in logical pixels.
+const juce::Rectangle statusArea { 470, 28, 310, 20 };
+const juce::Rectangle hostSettingsArea { 470, 54, 310, 16 };
+const juce::Rectangle screenArea { 36, 86, 748, 238 };
+const juce::Rectangle summaryArea { 480, 330, 300, 14 };
+constexpr int knobAxis = 434;
+constexpr int knobCentres[] { 130, 296, 462 };
+const juce::Rectangle listenArea { 600, 368, 170, 130 };
+
+const juce::Colour screenTeal { 0xff35e0c8 }, legendWhite { 0xffd8dbdf };
+
+// Reduction reads on a square-root law, as on the family's reduction meters.
+float depthFor (float decibels) { return std::sqrt (juce::jlimit (0.0f, 1.0f, decibels / 60.0f)); }
} // namespace
affine::Theme SuppressorTheme::theme()
{
affine::Theme t;
- t.panel.base = juce::Colour (0xff2a3037);
- t.panel.brushing = 0.45f;
- t.knob.pointer = juce::Colour (0xff1c1f22);
- t.palette.accent = juce::Colour (0xff4fd6ff);
+ t.panel.texture = affine::PanelFinish::Texture::powder;
+ t.panel.base = juce::Colour (0xff17181b);
+ t.panel.grain = 0.10f;
+ t.panel.mottle = 0.03f;
+ t.panel.sheen = 0.5f;
+
+ using Material = affine::KnobFinish::Material;
+ t.knob.cap = juce::Colour (0xff34373c);
+ t.knob.body = juce::Colour (0xff141518);
+ t.knob.capMaterial = Material::spunAluminium;
+ t.knob.bodyMaterial = Material::anodised;
+ t.knob.pointer = juce::Colour (0xfff2f4f7);
+ t.knob.index = juce::Colour (0xfff2f4f7);
+
+ auto& p = t.palette;
+ p.silkscreen = legendWhite;
+ p.silkscreenDim = juce::Colour (0xff8a9097);
+ p.accent = juce::Colour (0xffeef4ff);
+ p.attention = juce::Colour (0xffffb238);
+ p.danger = juce::Colour (0xffff4a3d);
+ p.glass = juce::Colour (0xff050607);
+ p.glassTint = juce::Colour (0xff0c0f12);
return t;
}
+ReductionScreen::ReductionScreen()
+{
+ history.fill (std::numeric_limits::quiet_NaN());
+ setInterceptsMouseClicks (false, false);
+ setAccessible (false);
+}
+
+void ReductionScreen::setTheme (const affine::Theme& newTheme)
+{
+ theme = newTheme;
+ repaint();
+}
+
+void ReductionScreen::push (float reductionDb, bool hasReduction, float inputDb, float outputDb, bool isLive,
+ bool inputClip, bool outputClip, bool removed, bool multiband)
+{
+ const auto wasIdle = valid == 0 && ! live;
+ auto& slot = history[static_cast (head)];
+ if (! std::isnan (slot))
+ --valid;
+ slot = hasReduction ? juce::jmax (0.0f, reductionDb) : std::numeric_limits::quiet_NaN();
+ if (hasReduction)
+ ++valid;
+ head = (head + 1) % frames;
+
+ const auto captionsChanged = removed != removedSignal || multiband != multi;
+ latest = reductionDb;
+ current = hasReduction;
+ live = isLive;
+ input = inputDb;
+ output = outputDb;
+ inputClipped = inputClip;
+ outputClipped = outputClip;
+ removedSignal = removed;
+ multi = multiband;
+ if (wasIdle && valid == 0 && ! live && ! captionsChanged)
+ return;
+ repaint();
+}
+
+void ReductionScreen::paint (juce::Graphics& g)
+{
+ const auto glass = getLocalBounds().toFloat().reduced (5.0f);
+ affine::render::screenGlass (g, glass);
+
+ auto inner = glass.reduced (18.0f, 14.0f);
+ auto levels = inner.removeFromRight (184.0f);
+ inner.removeFromRight (24.0f);
+ g.setColour (screenTeal.withAlpha (0.10f));
+ g.fillRect (levels.getX() - 12.5f, inner.getY() + 4.0f, 1.0f, inner.getHeight() - 8.0f);
+ paintHistory (g, inner);
+
+ levels = levels.withSizeKeepingCentre (levels.getWidth(), 160.0f);
+ paintLevel (g, levels.removeFromTop (62.0f), "INPUT", input, inputClipped, screenTeal);
+ paintLevel (g, levels.removeFromBottom (62.0f), removedSignal ? "REMOVED" : "OUTPUT", output, outputClipped,
+ removedSignal ? theme.palette.attention : screenTeal);
+}
+
+void ReductionScreen::paintHistory (juce::Graphics& g, juce::Rectangle area)
+{
+ const auto dim = screenTeal.withAlpha (0.45f);
+ auto header = area.removeFromTop (36.0f);
+ g.setColour (screenTeal.withAlpha (0.85f));
+ g.setFont (affine::fonts::label (12.0f, 0.22f));
+ g.drawText (multi ? "MAX BAND REDUCTION" : "HIGH-BAND REDUCTION", header.withHeight (15.0f),
+ juce::Justification::centredLeft, false);
+ g.setColour (dim);
+ g.setFont (affine::fonts::label (10.0f, 0.2f));
+ g.drawText ("dB, LAST SIX SECONDS", header.withTrimmedTop (18.0f).withHeight (12.0f), juce::Justification::centredLeft, false);
+
+ auto unit = header.removeFromRight (24.0f);
+ g.setColour (current ? screenTeal : dim);
+ g.setFont (affine::fonts::label (12.0f, 0.1f));
+ g.drawText ("dB", unit.withTrimmedBottom (3.0f), juce::Justification::bottomRight, false);
+ const auto reading = ! current ? juce::String ("--") : latest >= 60.0f ? juce::String ("60+") : juce::String (latest, 1);
+ readoutGlow.draw (g, reading, affine::fonts::readout (32.0f), header.withTrimmedRight (4.0f), juce::Justification::centredRight,
+ current ? screenTeal.interpolatedWith (juce::Colours::white, 0.2f) : screenTeal.withAlpha (0.35f), 4.0f,
+ current ? 0.9f : 0.3f);
+
+ area.removeFromTop (10.0f);
+ const auto timeAxis = area.removeFromBottom (16.0f);
+ const auto gutter = area.removeFromLeft (26.0f);
+ const auto plot = area;
+ const auto yFor = [&] (float decibels) { return plot.getY() + plot.getHeight() * depthFor (decibels); };
+ const auto xFor = [&] (int frame) { return plot.getX() + plot.getWidth() * static_cast (frame) / static_cast (frames - 1); };
+
+ g.setFont (affine::fonts::label (10.0f, 0.02f));
+ for (float mark : { 0.0f, 5.0f, 10.0f, 20.0f, 40.0f, 60.0f })
+ {
+ const auto y = yFor (mark);
+ g.setColour (screenTeal.withAlpha (mark == 0.0f ? 0.22f : 0.08f));
+ g.fillRect (plot.getX(), y - 0.5f, plot.getWidth(), 1.0f);
+ g.setColour (dim);
+ g.drawText (whole (mark), juce::Rectangle (gutter.getWidth() - 7.0f, 12.0f).withPosition (gutter.getX(), y - 6.0f),
+ juce::Justification::centredRight, false);
+ }
+ g.setColour (screenTeal.withAlpha (0.07f));
+ for (int second = 1; second < 6; ++second)
+ {
+ const auto x = plot.getX() + plot.getWidth() * static_cast (second) / 6.0f;
+ for (auto y = plot.getY() + 2.0f; y < plot.getBottom(); y += 4.0f)
+ g.fillRect (x - 0.5f, y, 1.0f, 1.5f);
+ }
+ g.setColour (dim);
+ g.drawText ("-6 S", timeAxis.withWidth (40.0f).withX (plot.getX()), juce::Justification::centredLeft, false);
+ g.drawText ("-3 S", juce::Rectangle (40.0f, timeAxis.getHeight()).withCentre ({ plot.getCentreX(), timeAxis.getCentreY() }),
+ juce::Justification::centred, false);
+ g.drawText ("NOW", timeAxis.withLeft (plot.getRight() - 40.0f).withRight (plot.getRight()), juce::Justification::centredRight, false);
+
+ // One filled shape per unbroken run of readings; gaps are frames without a reading.
+ juce::Path fill, edge;
+ bool inRun = false;
+ juce::Point last;
+ for (int i = 0; i <= frames; ++i)
+ {
+ const auto value = i < frames ? history[static_cast ((head + i) % frames)] : std::numeric_limits::quiet_NaN();
+ if (! std::isnan (value))
+ {
+ const juce::Point at { xFor (i), yFor (value) };
+ if (! inRun)
+ {
+ fill.startNewSubPath (at.x, plot.getY());
+ edge.startNewSubPath (at);
+ inRun = true;
+ }
+ else
+ {
+ edge.lineTo (at);
+ }
+ fill.lineTo (at);
+ last = at;
+ }
+ else if (inRun)
+ {
+ fill.lineTo (last.x, plot.getY());
+ fill.closeSubPath();
+ inRun = false;
+ }
+ }
+ g.setGradientFill (juce::ColourGradient (screenTeal.withAlpha (0.05f), 0.0f, plot.getY(),
+ screenTeal.withAlpha (0.42f), 0.0f, plot.getBottom(), false));
+ g.fillPath (fill);
+ const juce::PathStrokeType::JointStyle joint = juce::PathStrokeType::curved;
+ g.setColour (screenTeal.withAlpha (0.16f));
+ g.strokePath (edge, juce::PathStrokeType (5.0f, joint, juce::PathStrokeType::rounded));
+ g.setColour (screenTeal);
+ g.strokePath (edge, juce::PathStrokeType (1.6f, joint, juce::PathStrokeType::rounded));
+
+ if (current)
+ {
+ const juce::Point now { xFor (frames - 1), yFor (latest) };
+ affine::render::halo (g, now, 10.0f, screenTeal, 0.7f);
+ g.setColour (screenTeal.interpolatedWith (juce::Colours::white, 0.5f));
+ g.fillEllipse (juce::Rectangle (5.0f, 5.0f).withCentre (now));
+ }
+ else if (valid == 0)
+ {
+ g.setColour (screenTeal.withAlpha (0.4f));
+ g.setFont (affine::fonts::label (13.0f, 0.3f));
+ g.drawText (live ? "NO READING" : "NO AUDIO", plot, juce::Justification::centred, false);
+ }
+}
+
+void ReductionScreen::paintLevel (juce::Graphics& g, juce::Rectangle area, const juce::String& name, float level,
+ bool clipped, juce::Colour colour)
+{
+ const auto& palette = theme.palette;
+ auto top = area.removeFromTop (22.0f);
+ g.setColour (colour.withAlpha (0.85f));
+ g.setFont (affine::fonts::label (12.0f, 0.22f));
+ g.drawText (name, top, juce::Justification::centredLeft, false);
+ g.setColour (live ? colour.interpolatedWith (juce::Colours::white, 0.2f) : colour.withAlpha (0.35f));
+ g.setFont (affine::fonts::readout (19.0f));
+ g.drawText (! live ? juce::String ("--") : level <= -99.0f ? juce::String ("-inf") : juce::String (level, 1), top,
+ juce::Justification::centredRight, false);
+
+ area.removeFromTop (8.0f);
+ auto bar = area.removeFromTop (10.0f);
+ const auto clip = bar.removeFromRight (16.0f);
+ bar.removeFromRight (6.0f);
+ constexpr int segments = 40;
+ const auto pitch = bar.getWidth() / static_cast (segments);
+ const auto lit = live ? juce::jlimit (0.0f, 1.0f, (level + 60.0f) / 60.0f) * static_cast (segments) : 0.0f;
+ for (int i = 0; i < segments; ++i)
+ {
+ const auto decibels = -60.0f + 60.0f * (static_cast (i) + 0.5f) / static_cast (segments);
+ const auto zone = decibels > -3.0f ? palette.attention
+ : decibels > -12.0f ? colour.interpolatedWith (juce::Colours::white, 0.45f) : colour;
+ g.setColour (static_cast (i) < lit ? zone : zone.withAlpha (0.1f));
+ g.fillRect (bar.getX() + static_cast (i) * pitch, bar.getY(), pitch - 1.0f, bar.getHeight());
+ }
+ if (clipped)
+ affine::render::halo (g, clip.getCentre(), 14.0f, palette.danger, 0.6f);
+ g.setColour (clipped ? palette.danger : palette.danger.withAlpha (0.16f));
+ g.fillRect (clip);
+
+ area.removeFromTop (4.0f);
+ g.setFont (affine::fonts::label (9.5f, 0.02f));
+ for (int value : { -48, -36, -24, -12, -6, 0 })
+ {
+ const auto x = bar.getX() + bar.getWidth() * static_cast (value + 60) / 60.0f;
+ g.setColour (colour.withAlpha (0.3f));
+ g.fillRect (x - 0.5f, area.getY(), 1.0f, 3.0f);
+ g.setColour (colour.withAlpha (0.5f));
+ g.drawText (juce::String (value), juce::Rectangle (24.0f, 11.0f).withCentre ({ x, area.getY() + 10.0f }),
+ juce::Justification::centred, false);
+ }
+ g.setColour (palette.danger.withAlpha (clipped ? 1.0f : 0.6f));
+ g.drawText ("CLIP", juce::Rectangle (30.0f, 11.0f).withCentre ({ clip.getCentreX(), area.getY() + 10.0f }),
+ juce::Justification::centred, false);
+}
+
SuppressorEditor::SuppressorEditor (SuppressorProcessor& p)
: juce::AudioProcessorEditor (&p), proc (p), theme (SuppressorTheme::theme()), look (theme),
threshold (p.apvts, "threshold", "Highs below this level are reduced. Raise it until noise between notes is suppressed."),
@@ -39,12 +270,14 @@ SuppressorEditor::SuppressorEditor (SuppressorProcessor& p)
{
setLookAndFeel (&look);
setOpaque (true);
+ faceplate.setShowsScrews (false);
int order = 1;
for (auto* dial : { &threshold, &strength, &release })
{
dial->setTheme (theme);
dial->setDiameter (affine::metrics::knobMedium);
+ dial->setLedRing (true);
dial->setExplicitFocusOrder (order++);
addAndMakeVisible (*dial);
}
@@ -60,59 +293,41 @@ SuppressorEditor::SuppressorEditor (SuppressorProcessor& p)
listen.setTooltip ("Audition input minus processed audio, including filter phase differences; not isolated noise. Turn off to hear the processed signal.");
listen.setExplicitFocusOrder (4);
listen.setTheme (theme);
+ listen.setStyle (affine::KeyButton::Style::softKey);
+ listen.setKeySize (76.0f, 56.0f);
listen.setLampColour (theme.palette.attention);
listen.setLegend ("Listen", "Removed");
+ juce::Path headphones;
+ headphones.addCentredArc (0.5f, 0.62f, 0.4f, 0.52f, 0.0f, -juce::MathConstants::halfPi,
+ juce::MathConstants::halfPi, true);
+ headphones.addRoundedRectangle (0.02f, 0.55f, 0.22f, 0.43f, 0.06f);
+ headphones.addRoundedRectangle (0.76f, 0.55f, 0.22f, 0.43f, 0.06f);
+ listen.setGlyph (headphones);
addAndMakeVisible (listen);
- affine::NeedleMeter::Scale scale;
- // Square-root law: small reductions stay readable, deep gating still fits.
- scale.toPosition = [] (float decibels) { return std::sqrt (juce::jlimit (0.0f, 1.0f, decibels / 60.0f)); };
- scale.majors = { 0, 5, 10, 20, 30, 40, 60 };
- scale.minors = { 1, 2, 3, 4, 6, 7, 8, 9, 15, 25, 35, 50 };
- scale.format = [] (float v) { return whole (v); };
- scale.unit = "dB";
- scale.caption = "High-band reduction";
- reduction.setTheme (theme);
- reduction.setScale (scale);
- reduction.setBacklight (juce::Colour (0xfff2e4c4));
- addAndMakeVisible (reduction);
-
- for (auto* ladder : { &inputLadder, &outputLadder })
- {
- ladder->setTheme (theme);
- ladder->setRange (-60.0f, 0.0f, 20);
- ladder->setZones (-12.0f, -3.0f);
- addAndMakeVisible (*ladder);
- }
+ screen.setTheme (theme);
+ addAndMakeVisible (screen);
status.setName ("Processing status");
status.setTheme (theme);
- status.setJustificationType (juce::Justification::centredLeft);
- for (auto* readout : { &inputPeak, &outputPeak })
- {
- readout->setTheme (theme);
- readout->setShowsLamp (false);
- readout->setJustificationType (juce::Justification::centred);
- readout->setDisplayFont (affine::fonts::readout (13.5f));
- readout->setInterceptsMouseClicks (false, false);
- readout->setAccessible (false);
- }
+ status.setGlassVisible (false);
+ status.setJustificationType (juce::Justification::centredRight);
+ status.setDisplayFont (affine::fonts::label (14.0f, 0.24f));
hostSettings.setName ("Host settings");
hostSettings.setTheme (theme);
hostSettings.setGlassVisible (false);
hostSettings.setJustificationType (juce::Justification::centredRight);
hostSettings.setDisplayFont (affine::fonts::label (11.5f, 0.2f));
- hostSettings.setEmission (theme.palette.silkscreen, 1.0f);
+ hostSettings.setEmission (theme.palette.silkscreenDim, 1.0f);
hostSettings.setLampColour (theme.palette.attention);
meterSummary.setName ("Signal meters");
meterSummary.setTooltip ("Input and output: peak across channels, dBFS. Reduction: deepest gate attenuation, not overall loudness loss. 300 ms peak decay; clips held for one second.");
meterSummary.setFont (affine::fonts::label (11.0f, 0.22f));
meterSummary.setColour (juce::Label::textColourId, theme.palette.silkscreenDim);
- meterSummary.setJustificationType (juce::Justification::centred);
+ meterSummary.setJustificationType (juce::Justification::centredRight);
meterSummary.setBorderSize (juce::BorderSize (0));
for (juce::Component* c : { static_cast (&status), static_cast (&meterSummary),
- static_cast (&hostSettings), static_cast (&inputPeak),
- static_cast (&outputPeak) })
+ static_cast (&hostSettings) })
addAndMakeVisible (c);
setSize (width, height);
@@ -134,17 +349,13 @@ SuppressorEditor::~SuppressorEditor()
void SuppressorEditor::resized()
{
status.setBounds (statusArea);
- reduction.setBounds (meterArea);
- inputLadder.setBounds (inputColumn);
- outputLadder.setBounds (outputColumn);
- inputPeak.setBounds (inputReadout);
- outputPeak.setBounds (outputReadout);
+ hostSettings.setBounds (hostSettingsArea);
+ screen.setBounds (screenArea);
meterSummary.setBounds (summaryArea);
int i = 0;
for (auto* dial : { &threshold, &strength, &release })
dial->setBounds (dial->getBoundsForCentre ({ knobCentres[i++], knobAxis }));
- listen.setBounds (juce::Rectangle (96, 88).withCentre (listenCentre));
- hostSettings.setBounds (hostSettingsArea);
+ listen.setBounds (listenArea);
}
void SuppressorEditor::visibilityChanged()
@@ -197,14 +408,9 @@ void SuppressorEditor::timerCallback()
: meter.input < 0.00001f ? "No input" : audition ? "Listening to difference"
: meter.reduction > 0.5f ? "Suppressing" : "Passing signal";
status.setText (state, juce::dontSendNotification);
- if (! fresh)
- status.setEmission (palette.accent.withMultipliedBrightness (0.55f), 0.0f);
- else if (bypass)
- status.setEmission (palette.silkscreen, 0.0f);
- else if (audition || learning)
- status.setEmission (palette.attention, 1.0f);
- else
- status.setEmission (palette.accent, meter.input < 0.00001f ? 0.25f : 1.0f);
+ status.setEmission (fresh ? legendWhite : palette.silkscreenDim, fresh ? 1.0f : 0.0f);
+ status.setLampColour (bypass ? palette.danger : (audition || learning) ? palette.attention
+ : state == "Suppressing" ? screenTeal : palette.accent);
threshold.setEnabled (! multi);
strength.setEnabled (! multi);
@@ -236,68 +442,28 @@ void SuppressorEditor::timerCallback()
}
const bool hasReduction = fresh && ! learning && (bypass || meter.input >= 0.00001f);
- reduction.setCaption (multi ? "Max band reduction" : "High-band reduction");
- reduction.setReading (meter.reduction, hasReduction);
- inputLadder.setLevel (db (meter.input), fresh);
- outputLadder.setLevel (db (meter.output), fresh);
- inputLadder.setClip (fresh && (meter.flags & M::inputClip) != 0);
- outputLadder.setClip (fresh && (meter.flags & M::outputClip) != 0);
- inputPeak.setText (fresh ? levelText (meter.input) : "--", juce::dontSendNotification);
- outputPeak.setText (fresh ? levelText (meter.output) : "--", juce::dontSendNotification);
- const auto peakColour = fresh ? palette.accent : palette.accent.withMultipliedBrightness (0.5f);
- inputPeak.setEmission (peakColour, fresh ? 1.0f : 0.0f);
- outputPeak.setEmission (audition ? palette.attention : peakColour, fresh ? 1.0f : 0.0f);
-
- if (audition != auditionShown)
- {
- auditionShown = audition;
- repaint (outputColumn.withY (88).withHeight (20).expanded (40, 0));
- }
+ screen.push (meter.reduction, hasReduction, db (meter.input), db (meter.output), fresh,
+ fresh && (meter.flags & M::inputClip) != 0, fresh && (meter.flags & M::outputClip) != 0, audition, multi);
}
void SuppressorEditor::print (juce::Graphics& g)
{
- using namespace affine::silkscreen;
const auto& palette = theme.palette;
- wordmark (g, "Suppressor", "Guitar DI noise suppressor", { 40.0f, 22.0f }, palette);
- groove (g, 32.0f, static_cast (width) - 32.0f, 80.0f);
- legend (g, "In", juce::Rectangle (60, 16).withCentre ({ inputColumn.getCentreX(), 98 }).toFloat(), palette,
- juce::Justification::centred);
- frame (g, suppressionFrame, "Suppression", palette);
- frame (g, monitorFrame, "Monitor", palette);
-
- // Printed dBFS scales on the outer side of each column, and the clip legends.
- g.setFont (affine::fonts::label (9.5f, 0.02f));
- for (const auto* ladder : { &inputLadder, &outputLadder })
- {
- const auto span = ladder->getScaleSpan();
- const bool left = ladder == &inputLadder;
- const auto edge = left ? static_cast (ladder->getX()) - 4.0f : static_cast (ladder->getRight()) + 4.0f;
- for (int value : { 0, -6, -12, -24, -36, -48, -60 })
- {
- const auto y = span.getY() + span.getHeight() * static_cast (-value) / 60.0f;
- g.setColour (palette.silkscreen.withAlpha (0.5f));
- g.fillRect (left ? edge - 4.0f : edge, y - 0.5f, 4.0f, 1.0f);
- g.setColour (palette.silkscreenDim);
- g.drawText (juce::String (value), juce::Rectangle (24.0f, 10.0f).withCentre ({ left ? edge - 18.0f : edge + 18.0f, y }),
- left ? juce::Justification::centredRight : juce::Justification::centredLeft, false);
- }
- const auto clip = ladder->getClipCentre();
- g.setColour (palette.danger.withAlpha (0.85f));
- g.setFont (affine::fonts::label (9.0f, 0.12f));
- g.drawText ("CLIP", juce::Rectangle (30.0f, 10.0f).withCentre ({ left ? edge - 19.0f : edge + 19.0f, clip.y }),
- left ? juce::Justification::centredRight : juce::Justification::centredLeft, false);
- g.setFont (affine::fonts::label (9.5f, 0.02f));
- }
- makersMark (g, { 34.0f, static_cast (height) - 10.0f }, palette);
+ g.setColour (palette.silkscreen);
+ g.setFont (affine::fonts::label (30.0f, 0.45f));
+ g.drawText ("SUPPRESSOR", juce::Rectangle (38.0f, 20.0f, 400.0f, 36.0f), juce::Justification::centredLeft, false);
+ g.setColour (palette.silkscreenDim);
+ g.setFont (affine::fonts::label (11.5f, 0.3f));
+ g.drawText ("GUITAR DI NOISE SUPPRESSOR", juce::Rectangle (40.0f, 56.0f, 360.0f, 14.0f), juce::Justification::centredLeft, false);
+
+ // A hairline separates the Listen key from the three dials.
+ g.setColour (juce::Colours::white.withAlpha (0.07f));
+ g.fillRect (570.0f, 380.0f, 1.0f, 112.0f);
+ affine::silkscreen::makersMark (g, { 40.0f, static_cast (height) - 16.0f }, palette);
}
void SuppressorEditor::paint (juce::Graphics& g)
{
faceplate.paint (g, getLocalBounds(), theme, [this] (juce::Graphics& plate) { print (plate); });
- // The output column is labelled with what it is actually monitoring.
- affine::silkscreen::legend (g, auditionShown ? "Removed" : "Out",
- juce::Rectangle (90, 16).withCentre ({ outputColumn.getCentreX(), 98 }).toFloat(),
- theme.palette, juce::Justification::centred);
}
diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h
index 5d68382..799abea 100644
--- a/Source/PluginEditor.h
+++ b/Source/PluginEditor.h
@@ -5,7 +5,7 @@
namespace SuppressorTheme
{
-/** Graphite anodising with a glacier-cyan emission: the Suppressor finish of the Affine family. */
+/** Stealth finish: matte powder coat, white LED rings and a colour screen. */
affine::Theme theme();
}
@@ -15,6 +15,36 @@ class ListenButton final : public affine::KeyButton
ListenButton() : affine::KeyButton ("Listen removed") {}
};
+/**
+ The screen: gain reduction as a scrolling history, with input and output
+ peak bars. Only real snapshots are plotted; stretches without audio stay empty.
+*/
+class ReductionScreen final : public juce::Component
+{
+public:
+ ReductionScreen();
+
+ void setTheme (const affine::Theme&);
+ /** Appends one frame. `reductionDb` is ignored when `hasReduction` is false. */
+ void push (float reductionDb, bool hasReduction, float inputDb, float outputDb, bool live, bool inputClip,
+ bool outputClip, bool removed, bool multiband);
+ void paint (juce::Graphics&) override;
+
+private:
+ static constexpr int frames = 180; // six seconds at 30 Hz
+
+ void paintHistory (juce::Graphics&, juce::Rectangle);
+ void paintLevel (juce::Graphics&, juce::Rectangle, const juce::String& name, float level, bool clipped,
+ juce::Colour);
+
+ affine::Theme theme;
+ affine::render::GlowText readoutGlow;
+ std::array history {};
+ int head = 0, valid = 0;
+ float input = -100.0f, output = -100.0f, latest = 0.0f;
+ bool live = false, current = false, inputClipped = false, outputClipped = false, removedSignal = false, multi = false;
+};
+
class SuppressorEditor final : public juce::AudioProcessorEditor, private juce::Timer
{
public:
@@ -25,7 +55,7 @@ class SuppressorEditor final : public juce::AudioProcessorEditor, private juce::
void visibilityChanged() override;
int getControlParameterIndex (juce::Component&) override;
- static constexpr int width = 800, height = 530;
+ static constexpr int width = 820, height = 540;
private:
void timerCallback() override;
@@ -37,15 +67,14 @@ class SuppressorEditor final : public juce::AudioProcessorEditor, private juce::
affine::Knob threshold, strength, release;
ListenButton listen;
juce::AudioProcessorValueTreeState::ButtonAttachment listenAttachment;
- affine::NeedleMeter reduction;
- affine::LadderMeter inputLadder, outputLadder;
- affine::DisplayLabel status, inputPeak, outputPeak, hostSettings;
+ ReductionScreen screen;
+ affine::DisplayLabel status, hostSettings;
juce::Label meterSummary;
juce::TooltipWindow tooltips { this, 700 };
suppressor::MeterSnapshot meter;
uint32_t lastSequence = 0;
double lastUpdateMs = 0.0;
- bool fresh = false, auditionShown = false;
+ bool fresh = false;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SuppressorEditor)
};
diff --git a/Tests/test_ui.cpp b/Tests/test_ui.cpp
index fd89f15..64d458f 100644
--- a/Tests/test_ui.cpp
+++ b/Tests/test_ui.cpp
@@ -55,6 +55,39 @@ void feed (juce::AudioProcessor& p, int blocks = 120, bool silence = false, bool
if (bypass) p.processBlockBypassed (buffer, midi); else p.processBlock (buffer, midi);
}
}
+bool capturing() { return juce::SystemStats::getEnvironmentVariable ("SUPPRESSOR_UI_CAPTURE_DIR", {}).isNotEmpty(); }
+// Plucked notes over a hiss floor, processed at real-time pace so the editor's
+// reduction history fills with readings the processor actually produced.
+void playPlucks (juce::AudioProcessor& p)
+{
+ struct Pluck { double at, level; };
+ const Pluck notes[] { { 0.0, 0.30 }, { 0.9, 0.18 }, { 1.5, 0.34 }, { 2.9, 0.22 }, { 3.4, 0.12 }, { 4.6, 0.32 }, { 5.3, 0.2 } };
+ constexpr double length = 6.2, fundamental = 196.0;
+ juce::AudioBuffer buffer (2, 256);
+ juce::MidiBuffer midi;
+ juce::Random hiss (7);
+ const auto start = juce::Time::getMillisecondCounterHiRes();
+ const auto blocks = static_cast (length * 48000.0 / 256.0);
+ for (int b = 0; b < blocks; ++b)
+ {
+ for (int n = 0; n < 256; ++n)
+ {
+ const auto t = (b * 256 + n) / 48000.0;
+ auto sample = 0.002 * (hiss.nextDouble() * 2.0 - 1.0);
+ for (const auto& note : notes)
+ if (t >= note.at)
+ for (int k = 1; k <= 30; ++k)
+ sample += note.level / k * std::exp (-(t - note.at) * (1.5 + 0.35 * k))
+ * std::sin (juce::MathConstants::twoPi * fundamental * k * (t - note.at));
+ buffer.setSample (0, n, static_cast (sample));
+ buffer.setSample (1, n, static_cast (sample));
+ }
+ p.processBlock (buffer, midi);
+ const auto due = start + (b + 1) * 256.0 / 48.0;
+ while (juce::Time::getMillisecondCounterHiRes() < due)
+ pump (2);
+ }
+}
juce::MouseEvent event (juce::Component& c, juce::Point pos = { 60, 60 }, int modifiers = juce::ModifierKeys::leftButtonModifier)
{
const auto now = juce::Time::getCurrentTime();
@@ -322,6 +355,8 @@ TEST_CASE ("live meter states, stale data, legacy modes and monitor labels are e
feed (p);
expectStatus (*status, "Suppressing");
CHECK (summary->getDescription().contains ("High-band reduction"));
+ if (capturing())
+ playPlucks (p);
capture (*editor, "suppressor-ui");
for (float scale : { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f })
{
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index f4ce33b..1d5273b 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -1,11 +1,11 @@
# Suppressor interface
The editor implements the shared [Affine design language](../libs/affine-ui/DESIGN_LANGUAGE.md)
-(v1.0) through the vendored `affine_ui` JUCE module. This document records only the
-Suppressor-specific decisions.
+(v2.0) in its **Stealth** finish through the vendored `affine_ui` JUCE module. This
+document records only the Suppressor-specific decisions.
-
+
## Product
@@ -14,19 +14,18 @@ Suppressor-specific decisions.
high-gain amplification.
- **Primary task:** remove high-band noise between notes without losing body, sustain or
pick attack, and check what was removed when in doubt.
-- **Finish:** graphite anodising, glacier-cyan emission, an incandescent meter card.
-- **Editor:** fixed 800 × 530 logical pixels; renders natively at 100–200% and on Retina.
+- **Finish:** modern matte hardware: black powder coat, dark knobs with white pointers
+ and white LED rings, a rubber soft key, and one colour screen. Teal appears only on the
+ screen and the *Suppressing* LED.
+- **Editor:** fixed 820 × 540 logical pixels; renders natively at 100–200% and on Retina.
## Layout
-The signal runs left to right across the meter bridge, and the controls sit below it.
-
| Zone | Contents |
|---|---|
-| Header | Wordmark; processing-status display with lamp; *Host settings active* lamp underneath when non-default legacy settings exist |
-| Meter bridge | **IN** peak ladder → backlit **reduction** meter → **OUT** peak ladder (relabelled **REMOVED** while auditioning), each ladder with a clip cell and a peak readout |
-| Suppression frame | Threshold, Strength, Release: medium knobs with calibrated scales and glass readouts |
-| Monitor frame | *Listen removed* latching key with an amber LED |
+| Header | Wide-tracked wordmark and descriptor; the processing status with its LED; *Host settings active* underneath when non-default legacy settings exist |
+| Screen | Left: **high-band reduction** over the last six seconds, with the current value above the plot. Right: **INPUT** and **OUTPUT** peak bars (the output relabelled **REMOVED** while auditioning), each with a readout and a clip cell |
+| Controls | Threshold, Strength, Release: medium knobs with LED rings and glass readouts; after a hairline, *Listen removed* as a soft key with a headphones glyph |
## Telemetry contract
@@ -36,28 +35,30 @@ Listen and band-mode settings, so an old block is never relabelled as the new mo
| Display | Source | Presentation |
|---|---|---|
-| IN / OUT ladders | Peak across channels, 300 ms decay | −60 to 0 dBFS in 3 dB segments: cyan, amber from −12 dBFS, red from −3 dBFS |
-| Clip cells | Processor clip flags, held for one second | Separate red cell above each column |
+| Reduction plot | Deepest high-band gain reduction, 120 ms decay, sampled 30 times a second while the editor is open | 0–60 dB on a square-root scale, 0 dB at the top, newest at the right; the plot breaks wherever reduction was unavailable (no audio, no input, learning) and reads *NO AUDIO* once six seconds hold no readings |
+| Reduction readout | The latest reading | dB to 0.1, `60+` beyond the scale, `--` when unavailable |
+| INPUT / OUTPUT bars | Peak across channels, 300 ms decay | −60 to 0 dBFS in 1.5 dB segments: teal, pale from −12 dBFS, amber from −3 dBFS |
+| Clip cells | Processor clip flags, held for one second | Separate red cell at the end of each bar |
| Peak readouts | Same peaks | dBFS to 0.1 dB, `-inf` for silence, `--` when not live |
-| Reduction meter | Deepest high-band gain reduction, 120 ms decay | 0–60 dB on a square-root scale so small reductions stay readable; lamp dims and needle rests when reduction is unavailable (no audio, no input, learning) |
| Status | Snapshot flags | *No audio*, *No input*, *Passing signal*, *Suppressing*, *Listening to difference*, *Bypassed*, *Learning bands* |
-The needle's spring-damper ballistics (about 300 ms to settle) are presentation only.
+The plot shows stored readings only: nothing is interpolated across gaps or extrapolated
+past the newest reading.
## States
| State | Presentation |
|---|---|
-| Suppressing | Cyan status lamp, needle on the reduction, ladders live |
-| Listen removed | Amber key LED and status, **REMOVED** legend and amber readout on the output column |
-| Multiband topology | Meter caption reads **MAX BAND REDUCTION**; Threshold and Strength are disabled (veiled) because that mode uses its own splits and learned thresholds |
-| Learning bands | Amber status; reduction marked unavailable, so the meter lamp dims |
-| Bypassed | Needle at rest with the lamp lit (no reduction is applied) |
-| Stopped audio | Lamp dims, needle rests, ladders dark, readouts `--` |
+| Suppressing | Teal status LED, the plot follows the reduction, bars live |
+| Listen removed | Amber LED strip on the key and amber status LED; the output bar reads **REMOVED** in amber |
+| Multiband topology | The plot title reads **MAX BAND REDUCTION**; Threshold and Strength are disabled (their rings go dark) because that mode uses its own splits and learned thresholds |
+| Learning bands | Amber status LED; reduction is unavailable, so the plot breaks |
+| Bypassed | Red status LED; the plot runs along 0 dB (no reduction is applied) |
+| Stopped audio | Status LED off, bars dark, readouts `--`, the plot breaks |
-
-
+
+
## Parameter contract
@@ -65,7 +66,7 @@ The needle's spring-damper ballistics (about 300 ms to settle) are presentation
Unchanged: all 20 parameters keep their IDs, order, version hints, ranges, defaults,
tapers, text conversion and automation. The editor exposes Threshold, Strength, Release
and Delta Audition; the remaining engine settings stay available in the host's parameter
-view and are summarised by the *Host settings active* lamp's tooltip.
+view and are summarised by the *Host settings active* tooltip.
## Interaction
@@ -86,10 +87,10 @@ view and are summarised by the *Host settings active* lamp's tooltip.
- Every control exposes its parameter name, formatted value with unit, and a description.
- The *Signal meters* label carries a live summary (input and output dBFS, reduction or
- why it is unavailable, clip warnings); the per-column readouts are hidden from assistive
+ why it is unavailable, clip warnings); the screen itself is hidden from assistive
technology so values are not announced twice.
-- State is never colour-only: every lamp has a text legend and every mode change has a
- printed or displayed label.
+- State is never colour-only: the status is always spelled out next to its LED, and
+ every mode change has a printed or displayed label.
- Printed text meets 4.5:1 on the plate, including its darker lower edge; emitted
colours meet 4.5:1 on display glass. Both are unit-tested.
@@ -102,4 +103,6 @@ view and are summarised by the *Host settings active* lamp's tooltip.
- The DSP suite is unchanged and passes.
- pluginval 1.0.4 at strictness 10 reports `SUCCESS` for the VST3.
- With `SUPPRESSOR_UI_CAPTURE_DIR` set, the UI tests write a PNG of every state and scale;
- CI uploads them for macOS, Windows and Linux.
+ before the main capture they play six seconds of plucked notes at real-time pace, so
+ the plot shows readings the processor produced. CI uploads the captures for macOS,
+ Windows and Linux.
diff --git a/docs/images/suppressor-delta-audition.png b/docs/images/suppressor-delta-audition.png
index cc70073..6784a40 100644
Binary files a/docs/images/suppressor-delta-audition.png and b/docs/images/suppressor-delta-audition.png differ
diff --git a/docs/images/suppressor-exact-entry.png b/docs/images/suppressor-exact-entry.png
index 8999688..7427b6e 100644
Binary files a/docs/images/suppressor-exact-entry.png and b/docs/images/suppressor-exact-entry.png differ
diff --git a/docs/images/suppressor-learning.png b/docs/images/suppressor-learning.png
index f578b53..9fd3f21 100644
Binary files a/docs/images/suppressor-learning.png and b/docs/images/suppressor-learning.png differ
diff --git a/docs/images/suppressor-ui.png b/docs/images/suppressor-ui.png
index 478894a..5132457 100644
Binary files a/docs/images/suppressor-ui.png and b/docs/images/suppressor-ui.png differ
diff --git a/libs/affine-ui/DESIGN_LANGUAGE.md b/libs/affine-ui/DESIGN_LANGUAGE.md
index 1af08c4..40051bd 100644
--- a/libs/affine-ui/DESIGN_LANGUAGE.md
+++ b/libs/affine-ui/DESIGN_LANGUAGE.md
@@ -1,14 +1,19 @@
# Affine design language
-**Version 1.0** · shared by every Affine plug-in · implemented by the `affine_ui` JUCE module in this folder
+**Version 2.0** · shared by every Affine plug-in · implemented by the `affine_ui` JUCE module in this folder
-Affine plug-ins are **rendered laboratory instruments**. Each product is a believable piece of
-precision hardware from the same bench: an anodised faceplate, machined knobs with calibrated
-scales, backlit meters, glowing displays, all lit by one studio light. The hardware is a
-vehicle for clarity, not decoration: every lamp, needle and digit reports real state.
+Affine plug-ins are **rendered instruments**. Each product is a believable piece of
+hardware from the same bench: a real faceplate, knobs with calibrated scales, lit meters
+and displays, all lit by one studio light. The hardware is a vehicle for clarity, not
+decoration: every lamp, needle and digit reports real state.
+
+The kit renders four **finishes**: Laboratory, Broadcast, Hi-Fi and Stealth. A finish
+sets the materials, lamps and display technology. The principles, interaction contract,
+states and telemetry rules below apply to every finish, and all products in a release
+share one finish.
-
+
## Principles
@@ -16,19 +21,19 @@ vehicle for clarity, not decoration: every lamp, needle and digit reports real s
1. **One light.** A single softbox sits above and slightly left of the player. Every
highlight, reflection and shadow in every product agrees with it, so the instruments
read as physical objects and sit together on screen.
-2. **Real materials, rendered.** Anodised aluminium plate, spun-aluminium knob caps,
- knurled black grips, a backlit meter card, smoked display glass, neon and phosphor.
- Materials are shaded procedurally at the display's physical resolution, never
- stretched bitmaps.
+2. **Real materials, rendered.** Anodised aluminium, hammered enamel, powder coat and
+ black glass; spun and polished aluminium, bakelite and rubber; meter cards, Nixie
+ tubes, phosphor and LEDs. Materials are shaded procedurally at the display's physical
+ resolution, never stretched bitmaps.
3. **Truth before spectacle.** An instrument only shows what the processor actually did.
- Stale readings go dark (lamps dim, needles rest, tubes extinguish); nothing is
- extrapolated or animated for effect. Needle ballistics and glows are presentation only
- and never feed back into processing.
-4. **Printed like an instrument.** Labels are silkscreened, scales are calibrated through
- the parameter's own mapping (a log control gets a log scale), and controls are grouped
- by printed frames that follow the signal flow from left to right.
-5. **One hardware kit.** Products share knobs, keys, lamps, meters, displays, screws and
- typography. They differ in faceplate anodising, emission colour and layout.
+ Stale readings go dark (lamps dim, needles rest, tubes extinguish, plots break);
+ nothing is extrapolated or animated for effect. Needle ballistics and glows are
+ presentation only and never feed back into processing.
+4. **Printed like an instrument.** Labels are printed on the front, scales are calibrated
+ through the parameter's own mapping (a log control gets a log scale), and controls are
+ grouped by stage in signal-flow order, left to right.
+5. **One hardware kit.** Products share knobs, keys, lamps, meters, displays and
+ typography. Within a finish they differ only in colour and layout.
6. **Native behaviour is part of the design.** Every control has the same drag, fine,
exact-entry, reset, keyboard and host-menu behaviour, and every edit is one host gesture.
7. **Accessible by construction.** Every control has a role, name, formatted value and
@@ -44,7 +49,7 @@ vehicle for clarity, not decoration: every lamp, needle and digit reports real s
│ ◎ ◎ │
│ WORDMARK primary mode / status │ header band
│ DESCRIPTOR │
-│ ════════════════════════════════════════════════════════════════════ │ machined groove
+│ ──────────────────────────────────────────────────────────────────── │
│ instrument windows: meters, counters, displays (the hero) │ what is happening
│ │
│ ╭─ STAGE ───────────╮ ╭─ STAGE ─────────────────╮ ╭─ OUTPUT ─╮ │ what you can change,
@@ -55,50 +60,69 @@ vehicle for clarity, not decoration: every lamp, needle and digit reports real s
```
- **Header band:** wordmark and descriptor on the left; the product's primary mode or
- status on the right; a machined groove underneath.
+ status on the right.
- **Instrument windows:** the largest area shows what the plug-in is doing. Choose the
- display technology by the quantity (see below).
-- **Controls:** printed frames group controls by stage, in signal-flow order. Knob axes in
- a row share one horizontal line, as they would on a real panel.
-- **Maker's mark** bottom left; four corner screws at a 14 px inset; 40 px outer margins.
-
-## Tokens
-
-### Shared palette
-
-| Role | Value | Use |
+ display technology by the quantity and the finish (see below).
+- **Controls:** grouped by stage, in signal-flow order, with printed frames or titled
+ hairlines. Knob axes in a row share one horizontal line, as they would on a real panel.
+- **Maker's mark** bottom left. How the front is held is part of the finish: corner
+ screws (Laboratory), rack ears (Broadcast), end caps (Hi-Fi), nothing visible (Stealth).
+
+## Finishes
+
+| | Laboratory | Broadcast | Hi-Fi | Stealth |
+|---|---|---|---|---|
+| Front | Dark anodised aluminium, bead-blasted or brushed | Light hammertone enamel with rack ears and a riveted name plate | Black glass between polished aluminium end caps | Matte black powder coat |
+| Knobs | Spun-aluminium cap on a knurled black grip | Glossy bakelite with a ribbed skirt and a white pointer | Spun cap on a polished aluminium body | Dark spun cap, white pointer, white LED ring |
+| Keys | Anodised caps with LED windows | Piano keys; a chrome bat-handle toggle | Polished square buttons whose legends light | Rubber soft keys with LED strips |
+| Lamps | LEDs behind domed lenses | Faceted jewels in chrome bezels | Legends lit from behind the glass | LEDs |
+| Displays | Backlit meter card, LED ladders, Nixie tubes, VFD | Chrome-bezel VU meters, Nixie tubes, a phosphor scope | Blue-lit flush meters, a tuning dial, VFD, horizontal ladders | One colour screen: history plots, scopes, bars and numerals |
+| Readouts | Glowing characters on glass | Dark ink in amber-lit windows | Glowing characters on glass | Glowing characters on glass |
+| Printing | Light silkscreen, Michroma wordmark | Dark ink; the wordmark engraved on the name plate | Printed on the back of the glass and lit; green wordmark | Light silkscreen; wide-tracked condensed wordmark |
+
+A finish is a `Theme`: `PanelFinish` (texture, base colour, grain, mottle, sheen, and
+the hammertone dimple size), `KnobFinish` (cap and body materials and colours, cap and
+grip proportions, dome, skirt ridges) and a `Palette` (printing, emission, lamp style,
+readout style). Editors add the finish's fixing hardware and display choices.
+
+### Product colours
+
+| Finish | Suppressor | HDN Ring Modulator |
|---|---|---|
-| Silkscreen | `#ECE8DF` | Labels, legends, frames (at 30%), scale numbers |
-| Silkscreen dim | `#AEAAA1` | Descriptors, secondary legends |
-| Attention | `#FFB238` | Engaged monitoring or learning states, hot ladder segments |
-| Danger | `#FF4A3D` | Clip cells only |
-| Glass | `#07090B` | Display and readout windows |
+| Laboratory | Graphite `#2A3037`; glacier cyan `#4FD6FF`; incandescent meter card `#F2E4C4` | Petrol `#1F3538`; neon orange `#FF6A1C` Nixie carrier; VFD cyan `#5CF2D6` input |
+| Broadcast | Warm grey `#B3B0A6`; signal green `#2F9E4F`; VU card `#F2D48A` | Blue grey `#A9B0B5`; neon orange `#FF6A1C` Nixies; phosphor `#7DFFA0` scope |
+| Hi-Fi | Black glass `#050607`; meter light `#3A8EF0`; green legends `#54F28C` | The same glass and meter light; blue `#4AA8FF` dial pointer and readout; green VFD |
+| Stealth | Powder coat `#17181B`; white LEDs `#EEF4FF`; teal screen `#35E0C8` | The same coat and LEDs; amber screen `#FFB23F` |
-Printed text is at least 4.5:1 against the plate (checked against the plate's darker lower
-edge too) and emitted colours are at least 4.5:1 against glass; both are unit-tested.
+## Tokens
-### Product finishes
+### Palette roles
-A product chooses an anodising, one emission accent, and optionally a second display
-technology. Everything else is shared.
+Every finish defines the same roles; only the values change.
-| Product | Anodising | Emission accent | Other emitters |
-|---|---|---|---|
-| Suppressor | Graphite `#2A3037` | Glacier cyan `#4FD6FF` | Incandescent meter card `#F2E4C4` |
-| HDN Ring Modulator | Petrol `#1F3538` | Neon orange `#FF6A1C` (Nixie carrier) | VFD cyan `#5CF2D6` (input display) |
+| Role | Use |
+|---|---|
+| Silkscreen | Labels, legends, frames, scale numbers |
+| Silkscreen dim | Descriptors, secondary legends |
+| Accent | The product's emission: lamps, displays, LED rings, focus |
+| Attention | Amber `#FFB238` in every finish: engaged monitoring or learning states, hot segments |
+| Danger | Red in every finish: clip cells only |
+| Glass | Display and readout windows |
+| Readout backlight and ink | Backlit readouts only (Broadcast) |
-New products should pick an anodising that is distinct in hue but equally dark, and an
-accent that stays at least 4.5:1 on glass.
+Printed text is at least 4.5:1 against the plate (checked against the plate's darker lower
+edge too). Emitted colours are at least 4.5:1 against glass, and backlit readouts'
+ink at least 4.5:1 against their window. All three are unit-tested per product.
### Typography
| Role | Face | Size |
|---|---|---|
-| Wordmark | Michroma, tracked 0.18 | 21 px |
-| Frame titles, control labels | Barlow Condensed SemiBold, tracked 0.16–0.24 | 11.5–12.5 px, capitals |
+| Wordmark | Michroma (Laboratory, Hi-Fi) or Barlow Condensed SemiBold tracked 0.36–0.45 (Broadcast, Stealth) | 21–30 px |
+| Frame titles, control labels | Barlow Condensed SemiBold, tracked 0.16–0.26 | 11.5–12.5 px, capitals |
| Scale numbers | Barlow Condensed SemiBold | 9.5–10 px |
-| Readouts and status | Share Tech Mono | 13.5–15 px |
-| VFD characters | DSEG14 Classic Bold | 40–48 px |
+| Readouts and status | Share Tech Mono | 13.5–15 px; screen numerals up to 54 px |
+| VFD characters | DSEG14 Classic Bold | 30–48 px |
| Nixie cathodes | Nixie One, fitted to the cathode stack | tube height |
All faces are bundled under the SIL Open Font License; see `fonts/licenses`.
@@ -115,48 +139,54 @@ All faces are bundled under the SIL Open Font License; see `fonts/licenses`.
## Choosing a display
-| Quantity | Instrument | Component |
-|---|---|---|
-| A magnitude that moves musically (gain reduction) | Backlit moving-coil meter with mirror scale | `NeedleMeter` |
-| Peak level with clipping | LED ladder with a separate clip cell | `LadderMeter` |
-| A frequency or count | Nixie tube counter | `NixieDisplay` |
-| A symbol (note name) or short status | Vacuum-fluorescent characters | `DisplayLabel`, `GlowText` with DSEG14 |
-| A set value | Glass readout under its knob | built into `Knob` |
-| On/off state | Jewel lamp | `render::lamp` |
+| Quantity | Laboratory | Broadcast | Hi-Fi | Stealth |
+|---|---|---|---|---|
+| A magnitude that moves musically (gain reduction) | Backlit `NeedleMeter` with mirror scale | Chrome-bezel VU `NeedleMeter` | Flush blue `NeedleMeter` | Scrolling history plot on the screen |
+| Peak level with clipping | Vertical `LadderMeter` | VU `NeedleMeter` with clip jewel | Horizontal `LadderMeter` | Segmented bars on the screen |
+| A frequency or count | `NixieDisplay` | `NixieDisplay` | `TuningDial` with a readout | Screen numerals |
+| A note name or short status | `DisplayLabel`, `GlowText` in DSEG14 | Alphanumeric `NixieDisplay`; backlit `DisplayLabel` | VFD `GlowText`; `IlluminatedLegends` | Screen numerals and captions |
+| A set value | Glass readout under its knob | Backlit readout under its knob | Glass readout under its knob | Glass readout under its knob |
+| On/off state | LED `render::indicator` | Jewel `render::indicator` | Lit legend | LED |
+
+Screens are drawn by the product on `render::screenGlass`; they plot only stored readings
+and leave gaps where there were none.
## Components
| Component | Contract |
|---|---|
-| `Knob` | Rendered knob with printed calibrated scale, label and glass readout. Drag anywhere (up/right increases), Shift for fine, double-click or Return for exact entry (Return commits, Escape cancels, malformed text never changes the value), Alt/Option-click or Home resets, arrows step, right-click opens the parameter menu with host items. Optional activity lamp for *inactive but editable* states. |
-| `KeyButton` | Latching key with an LED window and printed legend; Space and Return operate it. |
-| `SelectorKeys` | One lit key per choice of a choice parameter, in a row or grid; each press is one complete gesture; arrows move the selection. |
-| `NeedleMeter` | Lamp dims and needle rests when the reading is not live; spring-damper ballistics settle in about 300 ms. |
-| `LadderMeter` | Accent / attention / danger zones, smooth top segment, clip cell apart from the scale. |
-| `NixieDisplay` | Right-aligned digits, decimal cathode, ghost cathodes and honeycomb anode always visible; dark when the reading is absent. |
-| `DisplayLabel` | A `juce::Label` that renders as a glowing display with a status lamp; the accessible text stays the label text. |
-| `Faceplate` | Caches the plate texture, edge, screws and everything silkscreened on it per physical scale. |
-| `silkscreen::*` | Wordmark, groove, frame, legend, maker's mark, screws. |
+| `Knob` | Rendered knob with a printed calibrated scale or an LED ring, a label and a readout. Drag anywhere (up/right increases), Shift for fine, double-click or Return for exact entry (Return commits, Escape cancels, malformed text never changes the value), Alt/Option-click or Home resets, arrows step, right-click opens the parameter menu with host items. Optional activity lamp for *inactive but editable* states. |
+| `KeyButton` | Latching key in one of five styles (anodised cap, piano key, bat-handle toggle, polished square, soft key) with a printed legend and optional glyph; Space and Return operate it. |
+| `SelectorKeys` | One key per choice of a choice parameter, in a row or grid; each press is one complete gesture; arrows move the selection. |
+| `NeedleMeter` | Moulded, chrome or flush housing; lamp dims and needle rests when the reading is not live; spring-damper ballistics (VU-like by default); optional coloured zone and centre rest position. |
+| `LadderMeter` | Vertical or horizontal; accent, attention and danger zones; smooth top segment; clip cell apart from the scale. |
+| `NixieDisplay` | Numeral or alphanumeric tubes; right-aligned text, decimal cathode, ghost cathodes and honeycomb anode always visible; dark when the reading is absent. |
+| `TuningDial` | A lit frequency dial with a gliding pointer that parks at the left stop without a reading. |
+| `IlluminatedLegends` | A `juce::Label` shown as a row of printed legends of which the current one lights; the accessible text stays the label text. |
+| `DisplayLabel` | A `juce::Label` shown as glowing characters on glass or dark ink on a lit window, with a status lamp; the accessible text stays the label text. |
+| `Faceplate` | Caches the plate texture, edge, screws and everything printed on it per physical scale. |
+| `render::*` | Faceplate textures, screws, recesses, lit windows, jewels, rack ears, name plates, end caps, screen glass, lamps, glow and halos. |
+| `silkscreen::*` | Wordmark, groove, frame, legend, lit legend, maker's mark, screws. |
| `LookAndFeel` | Menus, tooltips and text entry in the family style. |
## States
| State | Presentation |
|---|---|
-| Live | Lamps lit, needles move, digits glow |
-| No data or stale (no processed audio for 400–500 ms) | Lamps dim, needle rests at its stop, tubes and segments go dark, readouts show `--` |
-| Inactive but editable (the control does not act in the current mode) | Activity lamp off, label and readout dimmed; the control stays fully operable |
-| Disabled | Knob veiled, scale dimmed; not operable |
+| Live | Lamps lit, needles move, digits glow, plots advance |
+| No data or stale (no processed audio for 400–500 ms) | Lamps dim, needles rest at their stop, tubes and segments go dark, readouts show `--`, plots break |
+| Inactive but editable (the control does not act in the current mode) | Activity lamp off, label, readout and LED ring dimmed; the control stays fully operable |
+| Disabled | Knob veiled, scale or ring dark; not operable |
| Attention | Amber emission plus explicit text (e.g. `LISTENING TO DIFFERENCE`) |
-| Clip | Red clip cell, held by the processor for one second, plus accessible text |
+| Clip | Red clip cell or jewel, held by the processor for one second, plus accessible text |
| Keyboard focus | Illuminated ring at the foot of the knob, outline around keys |
## Adding a product
1. Copy this folder unchanged into the plug-in as `libs/affine-ui`, `include()` its
`AffineUI.cmake` after JUCE and link `affine_ui`.
-2. Define the product's `affine::Theme`: anodising, accent, and any second emitter.
-3. Lay out the header, instrument windows and framed control stages as above.
+2. Define the product's `affine::Theme` in the release's finish, with its own colours.
+3. Lay out the header, instrument windows and control stages as above.
4. Publish telemetry from the processor through atomics with a freshness signal (a block
counter or sequence) and present stale data as dark.
5. Add tests for contrast, control mapping and focus order, and a capture test that writes
diff --git a/libs/affine-ui/README.md b/libs/affine-ui/README.md
index e7aebf7..64209ec 100644
--- a/libs/affine-ui/README.md
+++ b/libs/affine-ui/README.md
@@ -18,7 +18,8 @@ target_link_libraries(MyPlugin PRIVATE affine_ui)
```cpp
#include
-affine::Theme theme; // shared palette, per-product finish
+affine::Theme theme; // the release's finish, in product colours
+theme.panel.texture = affine::PanelFinish::Texture::anodised;
theme.panel.base = juce::Colour (0xff2a3037);
theme.palette.accent = juce::Colour (0xff4fd6ff);
@@ -35,15 +36,16 @@ Requires JUCE 8 and C++17. The module depends on `juce_gui_basics` and
| Path | What |
|---|---|
-| `affine_ui/render` | Procedural renderers: knob shader, anodised faceplate, glow and blur |
-| `affine_ui/components` | `Knob`, `KeyButton`, `SelectorKeys`, `NeedleMeter`, `LadderMeter`, `NixieDisplay`, `DisplayLabel`, `Faceplate` and silkscreen helpers |
+| `affine_ui/render` | Procedural renderers: knob materials, faceplate textures (anodised, hammertone, powder, glass), panel hardware, glow and blur |
+| `affine_ui/components` | `Knob`, `KeyButton`, `SelectorKeys`, `NeedleMeter`, `LadderMeter`, `NixieDisplay`, `TuningDial`, `IlluminatedLegends`, `DisplayLabel`, `Faceplate` and silkscreen helpers |
| `affine_ui/theme` | Palette, metrics, fonts, `LookAndFeel` |
+| `docs/finishes.png` | Both products in each of the four finishes |
| `fonts` | Barlow Condensed, Michroma, Nixie One, DSEG14 Classic, Share Tech Mono (all SIL OFL 1.1; licences in `fonts/licenses`) |
-| `DESIGN_LANGUAGE.md` | Principles, tokens, anatomy, component contracts, states |
+| `DESIGN_LANGUAGE.md` | Principles, finishes, tokens, anatomy, component contracts, states |
## Rendering notes
-Knobs, the faceplate and meter faces are shaded per pixel at the display's physical
+Knobs, faceplates and meter faces are shaded per pixel at the display's physical
scale and cached, so they stay sharp at 100–200% and on Retina displays. Only the parts
that move are redrawn: the knob's knurled grip band is re-shaded (about a millisecond at
2x) when its value changes, and meters repaint their own bounds. Nothing here runs on the
diff --git a/libs/affine-ui/affine_ui/affine_ui.cpp b/libs/affine-ui/affine_ui/affine_ui.cpp
index 35bb236..8661d89 100644
--- a/libs/affine-ui/affine_ui/affine_ui.cpp
+++ b/libs/affine-ui/affine_ui/affine_ui.cpp
@@ -20,4 +20,5 @@
#include "components/affine_Keys.cpp"
#include "components/affine_Nixie.cpp"
#include "components/affine_Faceplate.cpp"
+#include "components/affine_Legends.cpp"
#include "theme/affine_LookAndFeel.cpp"
diff --git a/libs/affine-ui/affine_ui/affine_ui.h b/libs/affine-ui/affine_ui/affine_ui.h
index 63df2e8..92583aa 100644
--- a/libs/affine-ui/affine_ui/affine_ui.h
+++ b/libs/affine-ui/affine_ui/affine_ui.h
@@ -3,7 +3,7 @@
ID: affine_ui
vendor: affine
- version: 1.0.0
+ version: 2.0.0
name: Affine instrument UI
description: Shared rendered-instrument design language for Affine plug-ins
license: GPL-3.0-or-later
@@ -26,6 +26,7 @@
#include
#include
#include
+#include