Loading...
Searching...
No Matches
VUMeter.hpp
1#pragma once
2#include <Process/Dataflow/Port.hpp>
3#include <Process/Dataflow/PortFactory.hpp>
4#include <Process/Dataflow/PortItem.hpp>
5#include <Process/Process.hpp>
6
7#include <Effect/EffectLayer.hpp>
8#include <Effect/EffectLayout.hpp>
9
10#include <score/application/GUIApplicationContext.hpp>
11#include <score/model/Skin.hpp>
12
13#include <ossia/network/value/format_value.hpp>
14#include <ossia/network/value/value_conversion.hpp>
15
16#include <QPainter>
17
18#include <halp/audio.hpp>
19#include <halp/callback.hpp>
20#include <halp/controls.hpp>
21#include <halp/meta.hpp>
22
23#include <algorithm>
24#include <cmath>
25#include <vector>
26
27namespace Ui::VUMeter
28{
29struct Node
30{
31 halp_meta(name, "VU Meter")
32 halp_meta(c_name, "VUMeter")
33 halp_meta(category, "Monitoring")
34 halp_meta(author, "ossia score")
35 halp_meta(manual_url, "")
36 halp_meta(description, "Multi-channel audio level meter with peak and RMS display")
37 halp_meta(uuid, "0d0a3152-8ee9-4472-8a97-457b8bd6e56a")
38 halp_flag(fully_custom_item);
39
40 struct
41 {
42 halp::dynamic_audio_bus<"Audio", double> audio;
43 } inputs;
44
45 struct
46 {
47 // Levels output: interleaved [peak0, rms0, peak1, rms1, ...]
48 struct : halp::val_port<"Levels", std::vector<float>>
49 {
50 enum widget
51 {
52 control
53 };
54 } levels;
55 } outputs;
56
57 halp::setup setup_info{};
58
59 // Per-channel envelope state
61 {
62 double peak_env = 0.0;
63 double rms_env = 0.0;
64 double peak_hold = 0.0;
65 int peak_hold_counter = 0;
66 };
67 std::vector<ChannelState> channel_states;
68
69 // Release time in seconds — controls how fast the bars fall.
70 // Decrease for snappier response, increase for smoother visuals.
71 static constexpr double release_time = 0.150; // 150ms release
72 static constexpr int peak_hold_buffers = 20; // ~20 buffers hold time
73 static constexpr double peak_hold_decay = 0.90; // decay factor after hold expires
74
75 void prepare(halp::setup s) noexcept
76 {
77 setup_info = s;
78 channel_states.clear();
79 }
80
81 void operator()(int frames)
82 {
83 const int channels = inputs.audio.channels;
84 if(channels == 0)
85 return;
86
87 // Resize state if channel count changed
88 if(std::ssize(channel_states) != channels)
89 channel_states.resize(channels);
90
91 // Per-buffer release coefficient: accounts for buffer size so decay
92 // speed is independent of buffer size and sample rate.
93 const double rate = setup_info.rate > 0 ? setup_info.rate : 48000.0;
94 const double release = std::exp(-frames / (release_time * rate));
95
96 std::vector<float> level_data;
97 level_data.reserve(channels * 3);
98
99 for(int c = 0; c < channels; c++)
100 {
101 auto in = inputs.audio.channel(c, frames);
102 auto& state = channel_states[c];
103
104 // Compute peak and RMS for this buffer
105 double buf_peak = 0.0;
106 double buf_rms_sum = 0.0;
107 for(int i = 0; i < frames; i++)
108 {
109 const double s = std::abs(in[i]);
110 buf_peak = std::max(buf_peak, s);
111 buf_rms_sum += in[i] * in[i];
112 }
113 const double buf_rms = std::sqrt(buf_rms_sum / frames);
114
115 // Envelope following: instant attack, exponential release
116 if(buf_peak >= state.peak_env)
117 state.peak_env = buf_peak;
118 else
119 state.peak_env *= release;
120
121 if(buf_rms >= state.rms_env)
122 state.rms_env = buf_rms;
123 else
124 state.rms_env *= release;
125
126 // Peak hold with timed decay
127 if(buf_peak >= state.peak_hold)
128 {
129 state.peak_hold = buf_peak;
130 state.peak_hold_counter = peak_hold_buffers;
131 }
132 else if(state.peak_hold_counter > 0)
133 {
134 state.peak_hold_counter--;
135 }
136 else
137 {
138 state.peak_hold *= peak_hold_decay;
139 }
140
141 level_data.push_back(static_cast<float>(state.peak_env));
142 level_data.push_back(static_cast<float>(state.rms_env));
143 level_data.push_back(static_cast<float>(state.peak_hold));
144 }
145
146 outputs.levels.value = std::move(level_data);
147 }
148
149 // dB conversion utilities used by the Layer
150 static double to_dB(double linear) noexcept
151 {
152 if(linear <= 1e-10)
153 return -100.0;
154 return 20.0 * std::log10(linear);
155 }
156
157 static double dB_to_y(double dB, double height, double min_dB = -60.0) noexcept
158 {
159 // Map dB range [min_dB, 0] to y range [height, 0]
160 const double clamped = std::clamp(dB, min_dB, 0.0);
161 return height * (1.0 - (clamped - min_dB) / (0.0 - min_dB));
162 }
163
165 {
166 public:
167 static constexpr double min_dB = -60.0;
168 static constexpr double preferred_strip_width = 16.0;
169 static constexpr double min_strip_spacing = 1.0;
170 static constexpr double preferred_strip_spacing = 3.0;
171 static constexpr double label_width = 30.0;
172 static constexpr double top_margin = 4.0;
173 static constexpr double bottom_margin = 12.0;
174 // Hide channel labels when strips are narrower than this
175 static constexpr double min_width_for_labels = 8.0;
176
177 // Per-channel display data: peak, rms, peak_hold (linear)
179 {
180 float peak = 0.f;
181 float rms = 0.f;
182 float peak_hold = 0.f;
183 };
184 std::vector<ChannelDisplay> m_channels;
185
186 Layer(
187 const Process::ProcessModel& process, const Process::Context& doc,
188 QGraphicsItem* parent)
189 : Process::EffectLayerView{parent}
190 {
191 setAcceptedMouseButtons({});
192
193 const Process::PortFactoryList& portFactory
195
196 auto* audio_inlet = process.inlets().front();
197 auto fact = portFactory.get(audio_inlet->concreteKey());
198 auto port = fact->makePortItem(*audio_inlet, doc, this, this);
199 port->setPos(0, 5);
200
201 // Find the levels outlet (last outlet)
202 auto* levels_outlet
203 = static_cast<Process::ControlOutlet*>(process.outlets().back());
204 connect(
205 levels_outlet, &Process::ControlOutlet::valueChanged, this,
206 [this](const ossia::value& v) {
207 if(auto* list = v.target<std::vector<ossia::value>>())
208 {
209 const int entries = list->size();
210 // Data is interleaved: [peak0, rms0, hold0, peak1, rms1, hold1, ...]
211 const int num_channels = entries / 3;
212 m_channels.resize(num_channels);
213 for(int c = 0; c < num_channels; c++)
214 {
215 m_channels[c].peak = ossia::convert<float>((*list)[c * 3 + 0]);
216 m_channels[c].rms = ossia::convert<float>((*list)[c * 3 + 1]);
217 m_channels[c].peak_hold = ossia::convert<float>((*list)[c * 3 + 2]);
218 }
219 update();
220 }
221 });
222 }
223
224 void reset()
225 {
226 m_channels.clear();
227 update();
228 }
229
230 static QColor level_color(double dB) noexcept
231 {
232 // Green below -12dB, yellow from -12 to -3dB, red above -3dB
233 if(dB < -12.0)
234 return QColor(76, 175, 80); // green
235 else if(dB < -3.0)
236 {
237 // Interpolate green -> yellow
238 const double t = (dB + 12.0) / 9.0;
239 return QColor(
240 76 + static_cast<int>(179 * t),
241 175 + static_cast<int>(80 * t - 80 * t * t * 0.3),
242 80 - static_cast<int>(60 * t));
243 }
244 else
245 {
246 // Interpolate yellow -> red
247 const double t = std::min(1.0, (dB + 3.0) / 3.0);
248 return QColor(
249 255,
250 static_cast<int>(235 * (1.0 - t)),
251 static_cast<int>(20 * (1.0 - t)));
252 }
253 }
254
255 void paint_impl(QPainter* p) const override
256 {
257 const int num_ch = m_channels.size();
258 if(num_ch == 0)
259 return;
260
261 const auto bounds = boundingRect();
262 const double total_h = bounds.height();
263 const double meter_h = total_h - top_margin - bottom_margin;
264 if(meter_h <= 0)
265 return;
266
267 p->save();
268 p->setRenderHint(QPainter::Antialiasing, false);
269
270 const double start_x = label_width + 4.0;
271 const double avail_w = bounds.width() - start_x;
272
273 // Compute adaptive strip width and spacing
274 double sw = preferred_strip_width;
275 double sp = preferred_strip_spacing;
276 const double ideal_w = num_ch * sw + (num_ch - 1) * sp;
277 if(ideal_w > avail_w && num_ch > 0)
278 {
279 // Shrink spacing first, then strip width
280 sp = min_strip_spacing;
281 sw = (avail_w - (num_ch - 1) * sp) / num_ch;
282 if(sw < 1.0)
283 {
284 sp = 0.0;
285 sw = avail_w / num_ch;
286 }
287 }
288
289 if(sw <= 0)
290 {
291 p->restore();
292 return;
293 }
294
295 // Draw dB scale on left
296 draw_scale(p, start_x - 2.0, meter_h);
297
298 const bool show_labels = sw >= min_width_for_labels;
299
300 // Draw each channel strip
301 for(int c = 0; c < num_ch; c++)
302 {
303 const double x = start_x + c * (sw + sp);
304 draw_channel_strip(p, x, sw, meter_h, m_channels[c]);
305
306 if(show_labels)
307 {
308 p->setPen(QColor(180, 180, 180));
309 QFont f = p->font();
310 f.setPixelSize(9);
311 p->setFont(f);
312 const auto label = QString::number(c);
313 p->drawText(
314 QRectF(x, top_margin + meter_h + 1, sw, bottom_margin - 1),
315 Qt::AlignHCenter | Qt::AlignTop, label);
316 }
317 }
318
319 p->restore();
320 }
321
322 private:
323 void draw_scale(QPainter* p, double right_x, double meter_h) const
324 {
325 p->setPen(QColor(120, 120, 120));
326 QFont f = p->font();
327 f.setPixelSize(8);
328 p->setFont(f);
329
330 static constexpr double dB_marks[]
331 = {0, -3, -6, -12, -18, -24, -36, -48, -60};
332 for(double dB : dB_marks)
333 {
334 const double y = top_margin + dB_to_y(dB, meter_h, min_dB);
335 const auto label = (dB == 0) ? QStringLiteral(" 0")
336 : QString::number(static_cast<int>(dB));
337
338 p->drawText(
339 QRectF(0, y - 5, right_x - 2, 10), Qt::AlignRight | Qt::AlignVCenter,
340 label);
341
342 // Tick mark
343 p->drawLine(QPointF(right_x - 1, y), QPointF(right_x + 1, y));
344 }
345 }
346
347 void draw_channel_strip(
348 QPainter* p, double x, double sw, double meter_h,
349 const ChannelDisplay& ch) const
350 {
351 // Background
352 p->fillRect(
353 QRectF(x, top_margin, sw, meter_h), QColor(20, 20, 20));
354
355 const double peak_dB = Node::to_dB(ch.peak);
356 const double rms_dB = Node::to_dB(ch.rms);
357 const double hold_dB = Node::to_dB(ch.peak_hold);
358
359 // Draw RMS bar (full strip width minus 2px margin)
360 const double rms_w = std::max(1.0, sw - 2);
361 draw_level_bar(p, x + 1, meter_h, rms_dB, 0.6, rms_w);
362
363 // Draw peak bar (narrower, overlaid) — only if strip is wide enough
364 if(sw >= 6.0)
365 {
366 const double peak_bar_w = std::max(1.0, sw - 6);
367 const double peak_x = x + 3;
368 draw_level_bar(p, peak_x, meter_h, peak_dB, 1.0, peak_bar_w);
369 }
370 else
371 {
372 // Strip too narrow for two layers, just draw peak
373 draw_level_bar(p, x, meter_h, peak_dB, 1.0, sw);
374 }
375
376 // Draw peak hold indicator
377 if(hold_dB > min_dB)
378 {
379 const double hold_y = top_margin + dB_to_y(hold_dB, meter_h, min_dB);
380 p->setPen(Qt::NoPen);
381 const double hold_margin = std::min(1.0, sw * 0.1);
382 p->fillRect(
383 QRectF(x + hold_margin, hold_y - 1, sw - 2 * hold_margin, 2),
384 level_color(hold_dB));
385 }
386 }
387
388 void draw_level_bar(
389 QPainter* p, double x, double meter_h, double level_dB, double alpha,
390 double bar_w) const
391 {
392 if(level_dB <= min_dB)
393 return;
394
395 // Draw in segments for color gradient effect
396 static constexpr double segment_dB_step = 1.5;
397 double current_dB = min_dB;
398
399 p->setPen(Qt::NoPen);
400 while(current_dB < level_dB)
401 {
402 const double seg_top_dB = std::min(current_dB + segment_dB_step, level_dB);
403 const double y_bottom
404 = top_margin + dB_to_y(current_dB, meter_h, min_dB);
405 const double y_top
406 = top_margin + dB_to_y(seg_top_dB, meter_h, min_dB);
407
408 QColor col = level_color(seg_top_dB);
409 col.setAlphaF(alpha);
410 p->fillRect(QRectF(x, y_top, bar_w, y_bottom - y_top), col);
411
412 current_dB = seg_top_dB;
413 }
414 }
415
416 static double dB_to_y(double dB, double height, double min_dB) noexcept
417 {
418 const double clamped = std::clamp(dB, min_dB, 0.0);
419 return height * (1.0 - (clamped - min_dB) / (0.0 - min_dB));
420 }
421 };
422};
423}
Definition Port.hpp:427
Definition EffectLayer.hpp:16
Definition PortFactory.hpp:82
The Process class.
Definition score-lib-process/Process/Process.hpp:62
Base classes and tools to implement processes and layers.
Definition JSONVisitor.hpp:1115
Definition ProcessContext.hpp:12
Definition VUMeter.hpp:61
Definition VUMeter.hpp:165
Definition VUMeter.hpp:30
const T & interfaces() const
Access to a specific interface list.
Definition ApplicationContext.hpp:70