openMSX
ImGuiWaveViewer.cc
Go to the documentation of this file.
1#include "ImGuiWaveViewer.hh"
2
3#include "MSXMixer.hh"
4#include "MSXMotherBoard.hh"
5#include "SoundDevice.hh"
6
7#include "FFTReal.hh"
8#include "hammingWindow.hh"
9#include "fast_log2.hh"
10#include "halfband.hh"
11#include "narrow.hh"
12#include "ranges.hh"
13#include "view.hh"
14#include "xrange.hh"
15
16#include "imgui.h"
17#include "imgui_internal.h" // for ImLerp
18
19#include <algorithm>
20#include <cmath>
21#include <functional>
22#include <numbers>
23#include <numeric>
24
25namespace openmsx {
26
27// Simplified/optimized version of ImGui::PlotLines()
28static void plotLines(std::span<const float> values, float scale_min, float scale_max, gl::vec2 outer_size)
29{
30 const auto& style = ImGui::GetStyle();
31
32 gl::vec2 pos = ImGui::GetCursorScreenPos();
33 auto outer_tl = pos; // top-left
34 auto outer_br = pos + outer_size; // bottom-right
35 auto inner_tl = outer_tl + gl::vec2(style.FramePadding);
36 auto inner_br = outer_br - gl::vec2(style.FramePadding);
37
38 ImGui::RenderFrame(outer_tl, outer_br, ImGui::GetColorU32(ImGuiCol_FrameBg),
39 true, style.FrameRounding);
40
41 int num_values = narrow<int>(values.size());
42 if (num_values < 2) return;
43 int num_items = num_values - 1;
44
45 int inner_width = std::max(1, static_cast<int>(inner_br.x - inner_tl.x));
46 int res_w = std::min(inner_width, num_items);
47
48 float t_step = 1.0f / (float)res_w;
49 float scale_range = scale_max - scale_min;
50 float inv_scale = (scale_range != 0.0f) ? (1.0f / scale_range) : 0.0f;
51
52 auto color = ImGui::GetColorU32(ImGuiCol_PlotLines);
53
54 auto valueToY = [&](float v) {
55 return 1.0f - std::clamp((v - scale_min) * inv_scale, 0.0f, 1.0f);
56 };
57 float t = 0.0f;
58 auto tp0 = gl::vec2(t, valueToY(values[0]));
59 auto pos0 = ImLerp(inner_tl, inner_br, tp0);
60
61 auto* drawList = ImGui::GetWindowDrawList();
62 for (int n = 0; n < res_w; n++) {
63 auto idx = static_cast<int>(t * float(num_items) + 0.5f);
64 assert(0 <= idx && idx < num_values);
65 float v = values[(idx + 1) % num_values];
66
67 t += t_step;
68 auto tp1 = gl::vec2(t, valueToY(v));
69 auto pos1 = ImLerp(inner_tl, inner_br, tp1);
70 drawList->AddLine(pos0, pos1, color);
71 pos0 = pos1;
72 }
73}
74
75// Simplified/optimized version of ImGui::PlotHistogram()
76static void plotHistogram(std::span<const float> values, float scale_min, float scale_max, gl::vec2 outer_size)
77{
78 const auto& style = ImGui::GetStyle();
79
80 gl::vec2 pos = ImGui::GetCursorScreenPos();
81 auto outer_tl = pos; // top-left
82 auto outer_br = pos + outer_size; // bottom-right
83 auto inner_tl = outer_tl + gl::vec2(style.FramePadding);
84 auto inner_br = outer_br - gl::vec2(style.FramePadding);
85
86 ImGui::RenderFrame(outer_tl, outer_br, ImGui::GetColorU32(ImGuiCol_FrameBg),
87 true, style.FrameRounding);
88 if (values.empty()) return;
89
90 int num_values = narrow<int>(values.size());
91 int inner_width = std::max(1, static_cast<int>(inner_br.x - inner_tl.x));
92 int res_w = std::min(inner_width, num_values);
93
94 float t_step = 1.0f / static_cast<float>(res_w);
95 float scale_range = scale_max - scale_min;
96 float inv_scale = (scale_range != 0.0f) ? (1.0f / scale_range) : 0.0f;
97
98 auto valueToY = [&](float v) {
99 return 1.0f - std::clamp((v - scale_min) * inv_scale, 0.0f, 1.0f);
100 };
101 float zero_line = (scale_min * scale_max < 0.0f)
102 ? (1 + scale_min * inv_scale)
103 : (scale_min < 0.0f ? 0.0f : 1.0f);
104
105 auto color = ImGui::GetColorU32(ImGuiCol_PlotHistogram);
106
107 float t0 = 0.0f;
108 auto* drawList = ImGui::GetWindowDrawList();
109 drawList->PrimReserve(6 * res_w, 4 * res_w);
110
111 for (int n = 0; n < res_w; n++) {
112 auto idx = static_cast<int>(t0 * float(num_values) + 0.5f);
113 assert(0 <= idx && idx < num_values);
114 float y0 = valueToY(values[idx]);
115 float t1 = t0 + t_step;
116
117 auto pos0 = ImLerp(inner_tl, inner_br, gl::vec2(t0, y0));
118 auto pos1 = ImLerp(inner_tl, inner_br, gl::vec2(t1, zero_line));
119 drawList->PrimRect(pos0, pos1, color);
120
121 t0 = t1;
122 }
123}
124
125
126void ImGuiWaveViewer::save(ImGuiTextBuffer& buf)
127{
128 savePersistent(buf, *this, persistentElements);
129}
130
131void ImGuiWaveViewer::loadLine(std::string_view name, zstring_view value)
132{
133 loadOnePersistent(name, value, *this, persistentElements);
134}
135
136static void paintVUMeter(std::span<const float>& buf, float factor, bool muted)
137{
138 // skip if not visible
139 gl::vec2 pos = ImGui::GetCursorScreenPos();
140 ImGui::SetNextItemWidth(-FLT_MIN); // full cell-width
141 auto width = ImGui::CalcItemWidth();
142 auto height = ImGui::GetFrameHeight();
143 ImGui::Dummy(gl::vec2{width, height});
144 if (!ImGui::IsItemVisible()) return;
145 if (buf.size() <= 2) return;
146
147 // calculate the average power of the signal
148 auto len = float(buf.size());
149 auto avg = std::reduce(buf.begin(), buf.end()) / len;
150 auto squaredSum = std::transform_reduce(buf.begin(), buf.end(), 0.0f,
151 std::plus<>{}, [avg](float x) { auto norm = x - avg; return norm * norm; });
152 if (squaredSum == 0.0f) {
153 buf = {}; // allows to skip waveform and spectrum calculations
154 return;
155 }
156 auto power = fast_log2((squaredSum * factor * factor) / len);
157
158 // transform into a value for how to draw this [0, 1]
159 static constexpr auto convertLog = std::numbers::ln10_v<float> / std::numbers::ln2_v<float>; // log2 vs log10
160 static constexpr auto range = 6.0f * convertLog; // 60dB
161 auto clamped = std::clamp(power, -range, 0.0f);
162 auto f = (clamped + range) / range;
163
164 // draw gradient
165 auto size = gl::vec2{f * width, height};
166 auto colorL = muted ? ImVec4{0.2f, 0.2f, 0.2f, 1.0f} : ImVec4{0.0f, 1.0f, 0.0f, 1.0f}; // green
167 auto colorR = muted ? ImVec4{0.7f, 0.7f, 0.7f, 1.0f} : ImVec4{1.0f, 0.0f, 0.0f, 1.0f}; // red
168 auto color1 = ImGui::ColorConvertFloat4ToU32(colorL);
169 auto color2 = ImGui::ColorConvertFloat4ToU32(ImLerp(colorL, colorR, f));
170 auto* drawList = ImGui::GetWindowDrawList();
171 drawList->AddRectFilledMultiColor(
172 pos, pos + size,
173 color1, color2, color2, color1);
174}
175
176static void paintWave(std::span<const float> buf)
177{
178 // skip if not visible
179 auto pos = ImGui::GetCursorPos();
180 ImGui::SetNextItemWidth(-FLT_MIN); // full cell-width
181 auto size = gl::vec2{ImGui::CalcItemWidth(), ImGui::GetFrameHeight()};
182 ImGui::Dummy(size);
183 if (!ImGui::IsItemVisible()) return;
184 ImGui::SetCursorPos(pos);
185
186 if (buf.size() < 2) {
187 // otherwise PlotLines() doesn't draw anything, we want a line in the middle
188 static constexpr std::array<float, 2> silent = {0.0f, 0.0f};
189 buf = silent;
190 }
191 auto peak = std::transform_reduce(buf.begin(), buf.end(), 0.0f,
192 [](auto x, auto y) { return std::max(x, y); },
193 [](auto x) { return std::abs(x); });
194 if (peak == 0.0f) peak = 1.0f; // force line in the middle
195
196 plotLines(buf, -peak, peak, size);
197}
198
200 std::span<float> result; // down-sampled input
201 std::span<float> extendedResult; // zero-padded to power-of-2
202 float normalize = 1.0f; // down-sampling changes amplitude
203 float reducedSampleRate = 0.0f;
204};
205static ReduceResult reduce(std::span<const float> buf, std::span<float> work, size_t fftLen, float sampleRate)
206{
207 // allocate part of the workBuffer (like a monotonic memory allocator)
208 auto allocate = [&](size_t size) {
209 assert(size <= work.size());
210 auto result = work.first(size);
211 work = work.subspan(size);
212 return result;
213 };
214
215 // This function reduces (or rarely extends) the given buffer to exactly
216 // 2048 samples, while retaining the lower part of the frequency
217 // spectrum.
218 //
219 // Typically 'buf' contains too many samples. E.g. the PSG produces
220 // sound at ~224kHz, so 1/7 second takes ~31.9k samples. We're not
221 // interested in those very high frequencies. And we don't want to run a
222 // needlessly expensive FFT operation. Therefor this routine:
223 // * Filters away the upper-half of the frequency spectrum (via a
224 // half-band FIR filter).
225 // * Then drops every other sample (this doesn't change the spectrum if
226 // the filter has high enough quality).
227 // * Repeat until the buffer has 'fftLen' samples or less.
228 // * Finally zero-pad the result to exactly 'fftLen' samples (this does not
229 // change the spectrum).
230 float normalize = 1.0f;
231 std::span<float> extended;
232 if (buf.size() <= fftLen) {
233 extended = allocate(fftLen);
234 auto buf2 = extended.subspan(0, buf.size());
235 ranges::copy(buf, buf2);
236 buf = buf2;
237 } else {
238 assert(buf.size() >= HALF_BAND_EXTRA);
239 extended = allocate(std::max((buf.size() - HALF_BAND_EXTRA) / 2, size_t(fftLen)));
240 do {
241 static_assert(HALF_BAND_EXTRA & 1);
242 if ((buf.size() & 1) == 0) {
243 buf = buf.subspan(1); // drop oldest sample (need an odd number)
244 }
245 assert(buf.size() >= HALF_BAND_EXTRA);
246 auto buf2 = extended.subspan(0, (buf.size() - HALF_BAND_EXTRA) / 2);
247
248 halfBand(buf, buf2); // possibly inplace
249 normalize *= 0.5f;
250 sampleRate *= 0.5f;
251
252 buf = buf2;
253 } while (buf.size() > fftLen);
254 extended = extended.subspan(0, fftLen);
255 }
256 ranges::fill(extended.subspan(buf.size()), 0.0f);
257 auto result = extended.subspan(0, buf.size());
258 return {result, extended, normalize, sampleRate};
259}
260
261static std::string freq2note(float freq)
262{
263 static constexpr auto a4_freq = 440.0f;
264 static constexpr std::array<std::string_view, 12> names = {
265 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
266 };
267
268 auto n = int(std::lround(12.0f * fast_log2(freq / a4_freq))) + 9 + 4 * 12;
269 if (n < 0) return ""; // these are below 20Hz, so inaudible
270 auto note = n % 12;
271 auto octave = n / 12;
272 return strCat(names[note], octave);
273}
274
275static void paintSpectrum(std::span<const float> buf, float factor, const SoundDevice& device)
276{
277 static constexpr auto convertLog = std::numbers::ln10_v<float> / std::numbers::ln2_v<float>; // log2 vs log10
278 static constexpr auto range = 5.0f * convertLog; // 50dB
279
280 // skip if not visible
281 auto pos = ImGui::GetCursorPos();
282 ImGui::SetNextItemWidth(-FLT_MIN); // full cell-width
283 auto size = gl::vec2{ImGui::CalcItemWidth(), ImGui::GetFrameHeight()};
284 ImGui::Dummy(size);
285 if (!ImGui::IsItemVisible()) return;
286 ImGui::SetCursorPos(pos);
287
288 const auto& style = ImGui::GetStyle();
289 auto graphWidth = size.x - 2.0f * style.FramePadding.x;
290 auto fftLen = std::clamp<size_t>(2 * std::bit_ceil(size_t(graphWidth)), 256, 2048);
291 switch (fftLen) {
292 case 256: // 128 pixels-wide or less
293 buf = buf.last(buf.size() / 8); // keep last 1/8 (18ms, 56Hz bins)
294 break;
295 case 512: // 129..256 pixels (this is the default)
296 buf = buf.last(buf.size() / 4); // keep last 1/4 (36ms, 28Hz bins)
297 break;
298 case 1024: // 257..512 pixels
299 buf = buf.last(buf.size() / 2); // keep last 1/2 (71ms, 14Hz bins)
300 break;
301 default: // more than 513 pixels wide
302 // keep full buffer (143ms, 7Hz bins)
303 break;
304 }
305
306 float sampleRate = 0.0f;
307 std::array<float, 1023> magnitude_; // ok, uninitialized
308 std::span<float> magnitude;
309 if (buf.size() >= 2) {
310 // We want to take an FFT of fixed length (256, 512, 1024, 2048 points), reduce the
311 // input (decimate + zero-pad) to this exact length.
312 std::array<float, 32768> workBuf; // ok, uninitialized
313 auto [signal, zeroPadded, normalize, sampleRate_] = reduce(buf, workBuf, fftLen, device.getNativeSampleRate());
314 sampleRate = sampleRate_;
315 assert(zeroPadded.size() == fftLen);
316
317 // remove DC and apply window-function
318 auto window = hammingWindow(narrow<unsigned>(signal.size()));
319 auto avg = std::reduce(signal.begin(), signal.end()) / float(signal.size());
320 for (auto [s, w] : view::zip_equal(signal, window)) {
321 s = (s - avg) * w;
322 }
323
324 // perform FFT
325 std::array<float, 2048> tmp_; // ok, uninitialized
326 std::array<float, 2048> f_; // ok, uninitialized
327 switch (fftLen) {
328 case 256:
329 FFTReal<8>::execute(subspan<256>(zeroPadded), subspan<256>(f_), subspan<256>(tmp_));
330 break;
331 case 512:
332 FFTReal<9>::execute(subspan<512>(zeroPadded), subspan<512>(f_), subspan<512>(tmp_));
333 break;
334 case 1024:
335 FFTReal<10>::execute(subspan<1024>(zeroPadded), subspan<1024>(f_), subspan<1024>(tmp_));
336 break;
337 default:
338 FFTReal<11>::execute(subspan<2048>(zeroPadded), subspan<2048>(f_), subspan<2048>(tmp_));
339 break;
340 }
341 auto f = subspan(f_, 0, fftLen);
342
343 // combine real and imaginary components into magnitude (we ignore phase)
344 normalize *= factor;
345 auto offset = fast_log2(normalize * normalize * (1.0f / float(fftLen))) + range;
346 auto halfFftLen = fftLen / 2;
347 magnitude = subspan(magnitude_, 0, halfFftLen - 1);
348 for (unsigned i = 0; i < halfFftLen - 1; ++i) {
349 float real = f[i + 1];
350 float imag = f[i + 1 + halfFftLen];
351 float mag = real * real + imag * imag;
352 magnitude[i] = fast_log2(mag) + offset;
353 }
354 }
355
356 // actually plot the result
357 plotHistogram(magnitude, 0.0f, range, size);
358
359 simpleToolTip([&]() -> std::string {
360 auto scrnPosX = ImGui::GetCursorScreenPos().x + style.FramePadding.x;
361 auto mouseX = (ImGui::GetIO().MousePos.x - scrnPosX) / graphWidth;
362 if ((mouseX <= 0.0f) || (mouseX >= 1.0f)) return {};
363
364 if (sampleRate == 0.0f) {
365 // silent -> reduced sampleRate hasn't been calculated yet
366 sampleRate = device.getNativeSampleRate();
367 auto samples = device.getLastMonoBufferSize();
368 switch (fftLen) {
369 case 256: samples /= 8; break;
370 case 512: samples /= 4; break;
371 case 1024: samples /= 2; break;
372 default: /*nothing*/ break;
373 }
374 while (samples > fftLen) {
375 sampleRate *= 0.5f;
376 if ((samples & 1) == 0) --samples;
377 samples = (samples - HALF_BAND_EXTRA) / 2;
378 }
379 }
380
381 // format with "Hz" or "kHz" suffix and 3 significant digits
382 auto freq = std::lround(sampleRate * 0.5f * mouseX);
383 auto note = freq2note(float(freq));
384 if (freq < 1000) {
385 return strCat(freq, "Hz ", note);
386 } else {
387 auto k = freq / 1000;
388 auto t = (freq % 1000) / 10;
389 char t1 = char(t / 10) + '0';
390 char t2 = char(t % 10) + '0';
391 return strCat(k, '.', t1, t2, "kHz ", note);
392 }
393 });
394}
395
396static void stereoToMono(std::span<const float> stereo, float factorL, float factorR,
397 std::vector<float>& mono)
398{
399 assert((stereo.size() & 1) == 0);
400 auto size = stereo.size() / 2;
401 mono.resize(size);
402 for (auto i : xrange(size)) {
403 mono[i] = factorL * stereo[2 * i + 0]
404 + factorR * stereo[2 * i + 1];
405 }
406}
407
408static void paintDevice(SoundDevice& device, std::span<const MSXMixer::SoundDeviceInfo::ChannelSettings> settings)
409{
410 std::vector<float> tmpBuf; // recycle buffer for all channels
411
412 bool stereo = device.hasStereoChannels();
413 auto [factorL_, factorR_] = device.getAmplificationFactor();
414 auto factorL = factorL_; // pre-clang-16 workaround
415 auto factorR = factorR_;
416 auto factor = stereo ? 1.0f : factorL;
417
418 im::ID_for_range(device.getNumChannels(), [&](int channel) {
419 auto monoBuf = [&]{
420 auto buf = device.getLastBuffer(channel);
421 if (!stereo) return buf;
422 stereoToMono(buf, factorL, factorR, tmpBuf);
423 return std::span<const float>{tmpBuf};
424 }();
425
426 if (ImGui::TableNextColumn()) { // name
427 ImGui::StrCat(channel + 1);
428 }
429 auto& muteSetting = *settings[channel].mute;
430 bool muted = muteSetting.getBoolean();
431 if (ImGui::TableNextColumn()) { // mute
432 if (ImGui::Checkbox("##mute", &muted)) {
433 muteSetting.setBoolean(muted);
434 }
435 }
436 if (ImGui::TableNextColumn()) { // vu-meter
437 paintVUMeter(monoBuf, factor, muted);
438 }
439 if (ImGui::TableNextColumn()) { // waveform
440 paintWave(monoBuf);
441 }
442 if (ImGui::TableNextColumn()) { // spectrum
443 paintSpectrum(monoBuf, factor, device);
444 }
445 });
446}
447
448void ImGuiWaveViewer::paint(MSXMotherBoard* motherBoard)
449{
450 if (!show || !motherBoard) return;
451
452 ImGui::SetNextWindowSize(gl::vec2{38, 15} * ImGui::GetFontSize(), ImGuiCond_FirstUseEver);
453 im::Window("Audio channel viewer", &show, [&]{
454 for (const auto& info: motherBoard->getMSXMixer().getDeviceInfos()) {
455 auto& device = *info.device;
456 const auto& name = device.getName();
457 if (!ImGui::CollapsingHeader(name.c_str())) continue;
458 HelpMarker("Right-click column header to (un)hide columns.\n"
459 "Drag to reorder or resize columns.");
460
461 int flags = ImGuiTableFlags_RowBg |
462 ImGuiTableFlags_BordersV |
463 ImGuiTableFlags_BordersOuter |
464 ImGuiTableFlags_Resizable |
465 ImGuiTableFlags_Reorderable |
466 ImGuiTableFlags_Hideable |
467 ImGuiTableFlags_SizingStretchProp;
468 im::Table("##table", 5, flags, [&]{ // note: use the same id for all tables
469 ImGui::TableSetupScrollFreeze(0, 1); // Make top row always visible
470 ImGui::TableSetupColumn("ch.", ImGuiTableColumnFlags_NoReorder | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_WidthFixed);
471 // TODO: make this work here, or add an alternative: simpleToolTip("channel number");
472 ImGui::TableSetupColumn("mute", ImGuiTableColumnFlags_DefaultHide | ImGuiTableColumnFlags_NoResize | ImGuiTableColumnFlags_WidthFixed);
473 ImGui::TableSetupColumn("VU-meter", 0, 1.0f);
474 ImGui::TableSetupColumn("Waveform", 0, 2.0f);
475 ImGui::TableSetupColumn("Spectrum", 0, 3.0f);
476 ImGui::TableHeadersRow();
477 im::ID(name, [&]{
478 paintDevice(device, info.channelSettings);
479 });
480 });
481 }
482 });
483}
484
485} // namespace openmsx
TclObject t
static void execute(std::span< const float, FFT_LEN > input, std::span< float, FFT_LEN > output, std::span< float, FFT_LEN > tmpBuf)
Definition FFTReal.hh:43
void loadLine(std::string_view name, zstring_view value) override
void save(ImGuiTextBuffer &buf) override
const auto & getDeviceInfos() const
Definition MSXMixer.hh:141
Like std::string_view, but with the extra guarantee that it refers to a zero-terminated string.
float fast_log2(float x)
Definition fast_log2.hh:46
void halfBand(std::span< const float > in, std::span< float > out)
Definition halfband.hh:17
std::span< const float > hammingWindow(unsigned n)
void StrCat(Ts &&...ts)
Definition ImGuiUtils.hh:44
vecN< 2, float > vec2
Definition gl_vec.hh:382
vecN< N, T > normalize(const vecN< N, T > &x)
Definition gl_vec.hh:512
void Table(const char *str_id, int column, ImGuiTableFlags flags, const ImVec2 &outer_size, float inner_width, std::invocable<> auto next)
Definition ImGuiCpp.hh:455
void Window(const char *name, bool *p_open, ImGuiWindowFlags flags, std::invocable<> auto next)
Definition ImGuiCpp.hh:63
void ID(const char *str_id, std::invocable<> auto next)
Definition ImGuiCpp.hh:244
void ID_for_range(std::integral auto count, std::invocable< int > auto next)
Definition ImGuiCpp.hh:281
This file implemented 3 utility functions:
Definition Autofire.cc:11
bool loadOnePersistent(std::string_view name, zstring_view value, C &c, const std::tuple< Elements... > &tup)
void simpleToolTip(std::string_view desc)
Definition ImGuiUtils.hh:78
void savePersistent(ImGuiTextBuffer &buf, C &c, const std::tuple< Elements... > &tup)
void HelpMarker(std::string_view desc)
Definition ImGuiUtils.cc:23
constexpr void fill(ForwardRange &&range, const T &value)
Definition ranges.hh:315
constexpr auto copy(InputRange &&range, OutputIter out)
Definition ranges.hh:252
size_t size(std::string_view utf8)
Zip< false, RangesTuple, Is... > zip_equal(RangesTuple &&ranges, std::index_sequence< Is... >)
Definition view.hh:492
Definition view.hh:15
constexpr auto values(Map &&map)
Definition view.hh:531
constexpr auto subspan(Range &&range, size_t offset, size_t count=std::dynamic_extent)
Definition ranges.hh:481
std::string strCat()
Definition strCat.hh:703
std::span< float > extendedResult
std::span< float > result
constexpr auto xrange(T e)
Definition xrange.hh:132