wfview/include/spectrumwidget.h

254 lines
10 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#ifndef SPECTRUMWIDGET_H
#define SPECTRUMWIDGET_H
#include <QWidget>
#include <QPainter>
#include <QPen>
#include <QTimer>
#include <QElapsedTimer>
#include <QPolygonF>
#include <vector>
#include <cmath>
#include "logcategories.h"
// ─────────────────────────────────────────────────────────────────────────────
// SpectrumWidget — lightweight spectrum display for TX/RX audio processors.
//
// Two bin modes:
// logBins = true — bins are log-spaced (SPEC_BINS_PER_DECADE per decade,
// 50 Hz 8 kHz). Bin index maps linearly to x position.
// logBins = false — bins are linearly-spaced FFT bins; freq = i * binRes.
// Mapped to x via log10(freq).
//
// Maintained by the processing widget: it feeds spectrumPrimary (input, green)
// and spectrumSecondary (output, orange) with dBFS values.
// The repaint rate is controlled via setFps().
// ─────────────────────────────────────────────────────────────────────────────
class SpectrumWidget : public QWidget
{
Q_OBJECT
public:
explicit SpectrumWidget(QWidget *parent = nullptr) : QWidget(parent)
{
timer.setInterval(1000 / m_targetFps);
connect(&timer, &QTimer::timeout, this, QOverload<>::of(&SpectrumWidget::update));
timer.start();
m_paintLogTimer.start();
setBackgroundRole(QPalette::Dark);
setAutoFillBackground(true);
}
void setFps(int fps)
{
m_targetFps = qBound(1, fps, 60);
timer.setInterval(1000 / m_targetFps);
if (!timer.isActive()) timer.start();
}
// Stop the repaint timer entirely. The widget only repaints when
// the caller explicitly calls update() after setting new data.
void setStaticMode()
{
timer.stop();
}
// Set by AudioProcessingWidget to match the active DFT configuration.
int fftLength = 1024;
double sampleRate = 48000.0;
// Display range (dBFS).
double minDb = -90.0;
double maxDb = 0.0;
// Spacing between horizontal dBFS grid lines (dB). Change to taste.
double dbGridStep = 6.0;
// When true, bins are log-spaced (even resolution per octave on display).
// When false, bins are linearly-spaced FFT output (legacy).
bool logBins = false;
// Spectrum data — dBFS per bin.
// logBins=true: one value per log-spaced bin (numBins = decades × binsPerDecade).
// logBins=false: one value per FFT bin (bins 0 .. fftLength/2 - 1).
std::vector<double> spectrumPrimary; // input (pre-DSP) — green
std::vector<double> spectrumSecondary; // output (post-DSP) — orange
bool showSecondary = true;
// Legend labels (default: "Input" / "Output").
QString primaryLabel = tr("Input");
QString secondaryLabel = tr("Output");
// When true, skip any bin that would map to the same integer pixel as the
// previously drawn point. Avoids emitting more polyline vertices than the
// widget has horizontal pixels. Set to false to compare timings.
bool decimate = true;
// Mirror the global wfAntiAlias preference: when false, paint without
// QPainter::Antialiasing (faster, sharper pixels).
bool antiAlias = true;
protected:
void paintEvent(QPaintEvent *) override
{
QElapsedTimer t;
t.start();
QPainter painter(this);
if (antiAlias)
painter.setRenderHint(QPainter::Antialiasing);
painter.fillRect(rect(), QColor(20, 20, 20));
drawGrid(painter);
// Draw input (green) below output so output is always readable on top.
if (!spectrumPrimary.empty())
drawSpectrum(painter, spectrumPrimary, QColor(0, 200, 100));
if (showSecondary && !spectrumSecondary.empty())
drawSpectrum(painter, spectrumSecondary, QColor(255, 140, 0, 180));
// Legend
QFont f = painter.font();
f.setPointSize(8);
painter.setFont(f);
if (showSecondary) {
painter.setPen(QColor(0, 200, 100));
painter.drawText(6, 26, primaryLabel);
painter.setPen(QColor(255, 140, 0));
painter.drawText(6, 14, secondaryLabel);
} else if (!spectrumPrimary.empty()) {
painter.setPen(QColor(0, 200, 100));
painter.drawText(6, 14, primaryLabel);
}
// ── Paint timing (logged every second) ───────────────────────────────
m_paintTotalNs += t.nsecsElapsed();
++m_paintCallCount;
if (m_paintLogTimer.elapsed() >= 1000) {
const bool fpsMiss = m_paintCallCount < (m_targetFps * 8 / 10); // warn below 80%
Q_UNUSED(fpsMiss)
/*
qCDebug(logAudio) << "[SpectrumPaint]"
<< m_paintCallCount << "paints/s"
<< "(target" << m_targetFps << "fps)"
<< (fpsMiss ? "*** FPS NOT MET ***" : "")
<< ", avg"
<< (m_paintCallCount > 0
? m_paintTotalNs / m_paintCallCount / 1000
: 0LL)
<< "us/paint, total" << m_paintTotalNs / 1000000 << "ms/s";
*/
m_paintTotalNs = 0;
m_paintCallCount = 0;
m_paintLogTimer.restart();
}
}
private:
QTimer timer;
int m_targetFps = 10;
QElapsedTimer m_paintLogTimer;
qint64 m_paintTotalNs = 0;
int m_paintCallCount = 0;
void drawGrid(QPainter &p)
{
const QPen gridPen(QColor(50, 50, 50), 1, Qt::DashLine);
const QPen labelPen(QColor(90, 90, 90));
// Vertical grid lines — one per octave, 50 Hz to 8 kHz.
p.setPen(gridPen);
for (double freq = 50.0; freq <= 8000.0; freq *= 2.0) {
int x = freqToX(freq);
p.drawLine(x, 0, x, height());
p.setPen(labelPen);
QString label = (freq >= 1000.0)
? QString::number(freq / 1000.0, 'f', 1) + "k"
: QString::number(static_cast<int>(freq));
p.drawText(x + 3, height() - 4, label);
p.setPen(gridPen);
}
// Horizontal grid lines — one every effectiveStep dBFS.
// Auto-scale: if lines would be closer than (text height + margin) pixels,
// increase the step in 3 dB increments until they fit.
const double h = static_cast<double>(height());
const double range = maxDb - minDb;
const int minSpacing = p.fontMetrics().height() + 4; // margin in px
double effectiveStep = dbGridStep;
while (h * effectiveStep / range < minSpacing && effectiveStep < range)
effectiveStep += 3.0;
// Snap the first line to the nearest multiple of effectiveStep above minDb.
const double firstLine = std::ceil(minDb / effectiveStep) * effectiveStep;
for (double db = firstLine; db <= maxDb; db += effectiveStep) {
const int y = static_cast<int>(h - (db - minDb) / range * h);
p.setPen(gridPen);
p.drawLine(0, y, width(), y);
p.setPen(labelPen);
p.drawText(4, y - 2, QString::number(static_cast<int>(db)) + " dB");
}
}
void drawSpectrum(QPainter &p,
const std::vector<double> &data,
QColor color)
{
p.setPen(QPen(color, 1));
QPolygonF pts;
const int numBins = static_cast<int>(data.size());
if (numBins < 2) return;
int lastX = -1; // for decimation: skip bins mapping to same pixel column
if (logBins) {
// Bins are already log-spaced from 50 Hz to 8 kHz, so bin index
// maps linearly to x position (the x-axis is also log over the
// same range).
const double w = static_cast<double>(width());
const double h = static_cast<double>(height());
for (int i = 0; i < numBins; ++i) {
const double x = (static_cast<double>(i) / (numBins - 1)) * w;
if (decimate) {
const int xi = static_cast<int>(x);
if (xi == lastX) continue;
lastX = xi;
}
const double yN = (data[i] - minDb) / (maxDb - minDb);
const double y = h - yN * h;
pts << QPointF(x, y);
}
} else {
// Legacy: linearly-spaced FFT bins mapped via log frequency axis.
const double binRes = sampleRate / fftLength;
const int maxBin = qMin(numBins,
static_cast<int>(8000.0 / binRes));
for (int i = 1; i < maxBin; ++i) {
const double freq = i * binRes;
if (freq < 50.0) continue;
const int x = freqToX(freq);
if (decimate) {
if (x == lastX) continue;
lastX = x;
}
const float yNorm = static_cast<float>((data[i] - minDb) / (maxDb - minDb));
const float y = static_cast<float>(height()) - yNorm * static_cast<float>(height());
pts << QPointF(x, static_cast<double>(y));
}
}
p.drawPolyline(pts);
}
int freqToX(double freq) const
{
// Logarithmic frequency axis: 50 Hz at left edge, 8 kHz at right edge.
// Each octave occupies equal width — standard for audio spectrum displays.
// log10(50) ≈ 1.69897; log10(8000) ≈ 3.90309.
static constexpr double kLogMin = 1.6989700043360188;
static constexpr double kLogMax = 3.9030899869920159;
const double logF = std::log10(std::max(freq, 50.0));
return static_cast<int>((logF - kLogMin) / (kLogMax - kLogMin) * width());
}
};
#endif // SPECTRUMWIDGET_H