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