clean structure
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
#include "CoverArtWidget.h"
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
|
||||
CoverArtWidget::CoverArtWidget(QWidget *parent) : QWidget(parent) {
|
||||
setMinimumSize(130, 130);
|
||||
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||
}
|
||||
|
||||
void CoverArtWidget::setTrack(const engine::Track *t) {
|
||||
m_cover = (t && !t->coverArt.isNull()) ? t->coverArt : QImage{};
|
||||
m_title = t ? t->displayTitle() : "No track";
|
||||
m_artist = t ? t->artist : QString{};
|
||||
update();
|
||||
}
|
||||
|
||||
void CoverArtWidget::paintEvent(QPaintEvent *) {
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::SmoothPixmapTransform);
|
||||
int w = width(), h = height();
|
||||
|
||||
if (!m_cover.isNull()) {
|
||||
QPixmap pm = QPixmap::fromImage(m_cover);
|
||||
double sx = double(w)/pm.width(), sy = double(h)/pm.height();
|
||||
double sc = std::max(sx, sy);
|
||||
int dw = int(pm.width()*sc), dh = int(pm.height()*sc);
|
||||
p.drawPixmap((w-dw)/2, (h-dh)/2, dw, dh, pm);
|
||||
} else {
|
||||
p.fillRect(rect(), palette().window());
|
||||
p.setPen(palette().windowText().color());
|
||||
QFont f = p.font(); f.setBold(true); f.setPointSizeF(9); p.setFont(f);
|
||||
QString l1 = m_title.length()>20 ? m_title.left(18)+"…" : m_title;
|
||||
QString l2 = m_artist.length()>20 ? m_artist.left(18)+"…" : m_artist;
|
||||
QFontMetrics fm(p.font());
|
||||
p.drawText((w-fm.horizontalAdvance(l1))/2, h/2, l1);
|
||||
f.setBold(false); f.setPointSizeF(8); p.setFont(f);
|
||||
QFontMetrics fm2(p.font());
|
||||
p.drawText((w-fm2.horizontalAdvance(l2))/2, h/2+fm2.height()+2, l2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <QImage>
|
||||
#include "engine/Track.h"
|
||||
|
||||
class CoverArtWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit CoverArtWidget(QWidget *parent = nullptr);
|
||||
void setTrack(const engine::Track *t);
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
private:
|
||||
QImage m_cover;
|
||||
QString m_title { "No track" };
|
||||
QString m_artist;
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
#include "EqualizerWidget.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QInputDialog>
|
||||
#include <QFileDialog>
|
||||
#include <QStyleFactory>
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
static const inline QVector<QPair<QString,QString>> BUILTIN_PRESETS = {
|
||||
{"Flat", "0,0,0,0,0,0,0,0,0,0"},
|
||||
{"Bass Boost", "8,6,4,2,0,0,0,0,0,0"},
|
||||
{"Treble Boost", "0,0,0,0,0,0,2,4,6,8"},
|
||||
{"V-Shape", "6,4,2,-1,-3,-3,-1,2,4,6"},
|
||||
{"Rock", "4,3,1,0,-1,0,2,3,3,2"},
|
||||
{"Jazz", "3,2,1,2,0,-1,-1,0,1,2"},
|
||||
{"Classical", "0,0,0,0,0,0,-2,-3,-3,-2"},
|
||||
{"Electronic", "5,3,0,-1,-3,0,1,2,4,5"},
|
||||
};
|
||||
|
||||
static const inline QString FREQ_LABELS[] = {"32Hz","64Hz","125Hz","250Hz","500Hz","1kHz","2kHz","4kHz","8kHz","16kHz"};
|
||||
|
||||
QString EqualizerWidget::presetFile() { return QDir::homePath() + "/.subwave/eq_presets.json"; }
|
||||
QString EqualizerWidget::stateFile() { return QDir::homePath() + "/.subwave/eq_state.json"; }
|
||||
|
||||
EqualizerWidget::EqualizerWidget(AudioEngine *engine, QWidget *parent)
|
||||
: QWidget(parent), m_engine(engine)
|
||||
{
|
||||
auto *vlay = new QVBoxLayout(this);
|
||||
vlay->setSpacing(4);
|
||||
vlay->setContentsMargins(4,4,4,4);
|
||||
|
||||
auto *topBar = new QHBoxLayout;
|
||||
topBar->addWidget(new QLabel("Preset:"));
|
||||
m_presetBox = new QComboBox;
|
||||
m_presetBox->setStyle(QStyleFactory::create("Fusion"));
|
||||
topBar->addWidget(m_presetBox);
|
||||
auto *saveBtn = new QPushButton("Save...");
|
||||
auto *deleteBtn = new QPushButton("Delete");
|
||||
auto *resetBtn = new QPushButton("Reset");
|
||||
topBar->addWidget(saveBtn);
|
||||
topBar->addWidget(deleteBtn);
|
||||
topBar->addWidget(resetBtn);
|
||||
topBar->addStretch();
|
||||
vlay->addLayout(topBar);
|
||||
|
||||
m_curve = new FreqCurveWidget(this);
|
||||
m_curve->setMinimumHeight(80);
|
||||
vlay->addWidget(m_curve);
|
||||
|
||||
auto *sliderRow = new QHBoxLayout;
|
||||
sliderRow->setSpacing(2);
|
||||
for (int i = 0; i < BANDS; i++) {
|
||||
const int band = i;
|
||||
auto *col = new QVBoxLayout;
|
||||
col->setSpacing(1);
|
||||
col->setAlignment(Qt::AlignHCenter);
|
||||
|
||||
m_dbLabels[i] = new QLabel("0 dB");
|
||||
m_dbLabels[i]->setAlignment(Qt::AlignHCenter);
|
||||
// Fixed width and monospace to prevent left/right shifting
|
||||
QFont sf("Monospace", 8);
|
||||
sf.setStyleHint(QFont::Monospace);
|
||||
m_dbLabels[i]->setFont(sf);
|
||||
m_dbLabels[i]->setFixedWidth(50); // ample room for "-12 dB"
|
||||
|
||||
m_sliders[i] = new QSlider(Qt::Vertical);
|
||||
m_sliders[i]->setRange(-120, 120);
|
||||
m_sliders[i]->setValue(0);
|
||||
m_sliders[i]->setTickPosition(QSlider::TicksBothSides);
|
||||
m_sliders[i]->setTickInterval(60);
|
||||
|
||||
auto *freqLbl = new QLabel(FREQ_LABELS[i]);
|
||||
freqLbl->setAlignment(Qt::AlignHCenter);
|
||||
QFont ff = freqLbl->font(); ff.setPointSizeF(7); freqLbl->setFont(ff);
|
||||
|
||||
col->addWidget(m_dbLabels[i]);
|
||||
col->addWidget(m_sliders[i]);
|
||||
col->addWidget(freqLbl);
|
||||
sliderRow->addLayout(col);
|
||||
|
||||
connect(m_sliders[i], &QSlider::valueChanged, this, [this, band](int val){
|
||||
float db = val / 10.f;
|
||||
m_engine->setEqBand(band, db);
|
||||
m_dbLabels[band]->setText(QString("%1%2 dB")
|
||||
.arg(db >= 0 ? "+" : "").arg(db, 0, 'f', 0));
|
||||
m_curve->update();
|
||||
});
|
||||
}
|
||||
m_curve->setSliders(m_sliders);
|
||||
vlay->addLayout(sliderRow);
|
||||
|
||||
loadPresets();
|
||||
populatePresetBox();
|
||||
loadState();
|
||||
|
||||
connect(m_presetBox, QOverload<int>::of(&QComboBox::activated),
|
||||
this, [this](int idx) {
|
||||
m_presetBox->hidePopup(); // force-close before anything else
|
||||
m_presetBox->setEnabled(false);
|
||||
QMetaObject::invokeMethod(this, [this, idx] {
|
||||
applyPresetAt(idx);
|
||||
m_presetBox->setEnabled(true);
|
||||
}, Qt::QueuedConnection);
|
||||
});
|
||||
|
||||
connect(saveBtn, &QPushButton::clicked, this, [this]{
|
||||
bool ok = false;
|
||||
QString name = QInputDialog::getText(this, "Save Preset", "Preset name:", QLineEdit::Normal, "", &ok);
|
||||
if (!ok || name.trimmed().isEmpty()) return;
|
||||
QString vals;
|
||||
for (int i=0; i<BANDS; i++) {
|
||||
if (i) vals += ',';
|
||||
vals += QString::number(m_sliders[i]->value()/10.f,'f',1);
|
||||
}
|
||||
m_userPresets.removeIf([&](const QPair<QString,QString> &p){ return p.first == name; });
|
||||
m_userPresets.append({name, vals});
|
||||
savePresets();
|
||||
populatePresetBox();
|
||||
});
|
||||
connect(deleteBtn, &QPushButton::clicked, this, [this]{
|
||||
int idx = m_presetBox->currentIndex();
|
||||
int builtinCount = BUILTIN_PRESETS.size();
|
||||
if (idx < builtinCount) {
|
||||
QMessageBox::information(this, "SubWave", "Built-in presets cannot be deleted.");
|
||||
return;
|
||||
}
|
||||
int userIdx = idx - builtinCount;
|
||||
if (userIdx < 0 || userIdx >= m_userPresets.size()) return;
|
||||
m_userPresets.removeAt(userIdx);
|
||||
savePresets();
|
||||
populatePresetBox();
|
||||
});
|
||||
connect(resetBtn, &QPushButton::clicked, this, [this]{
|
||||
m_presetBox->hidePopup();
|
||||
m_presetBox->setCurrentIndex(0);
|
||||
m_presetBox->setEnabled(false);
|
||||
QMetaObject::invokeMethod(this, [this]{
|
||||
applyPresetAt(0);
|
||||
m_presetBox->setEnabled(true);
|
||||
}, Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
void EqualizerWidget::saveState() {
|
||||
QDir().mkpath(QFileInfo(stateFile()).absolutePath());
|
||||
QFile f(stateFile());
|
||||
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
|
||||
QJsonObject obj;
|
||||
QString vals;
|
||||
for (int i=0; i<BANDS; i++) {
|
||||
if (i) vals += ',';
|
||||
vals += QString::number(m_sliders[i]->value());
|
||||
}
|
||||
obj["bands"] = vals;
|
||||
f.write(QJsonDocument(obj).toJson());
|
||||
}
|
||||
|
||||
void EqualizerWidget::loadPresets() {
|
||||
QFile f(presetFile());
|
||||
if (!f.open(QIODevice::ReadOnly)) return;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||
m_userPresets.clear();
|
||||
if (!doc.isArray()) return;
|
||||
for (const QJsonValue &v : doc.array()) {
|
||||
QJsonObject obj = v.toObject();
|
||||
QString name = obj["name"].toString();
|
||||
QString bands = obj["bands"].toString();
|
||||
if (!name.isEmpty() && !bands.isEmpty())
|
||||
m_userPresets.append({name, bands});
|
||||
}
|
||||
}
|
||||
|
||||
void EqualizerWidget::savePresets() {
|
||||
QDir().mkpath(QFileInfo(presetFile()).absolutePath());
|
||||
QFile f(presetFile());
|
||||
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
|
||||
QJsonArray arr;
|
||||
for (const auto &pr : m_userPresets) {
|
||||
QJsonObject obj;
|
||||
obj["name"] = pr.first;
|
||||
obj["bands"] = pr.second;
|
||||
arr.append(obj);
|
||||
}
|
||||
f.write(QJsonDocument(arr).toJson());
|
||||
}
|
||||
|
||||
void EqualizerWidget::populatePresetBox() {
|
||||
QSignalBlocker blk(m_presetBox);
|
||||
m_presetBox->clear();
|
||||
for (auto &pr : BUILTIN_PRESETS) m_presetBox->addItem(pr.first);
|
||||
for (auto &pr : m_userPresets) m_presetBox->addItem(pr.first + " ★");
|
||||
}
|
||||
|
||||
void EqualizerWidget::applyPresetAt(int idx) {
|
||||
if (idx < 0) return;
|
||||
QString vals;
|
||||
if (idx < BUILTIN_PRESETS.size())
|
||||
vals = BUILTIN_PRESETS[idx].second;
|
||||
else {
|
||||
int ui = idx - BUILTIN_PRESETS.size();
|
||||
if (ui < 0 || ui >= m_userPresets.size()) return;
|
||||
vals = m_userPresets[ui].second;
|
||||
}
|
||||
QStringList parts = vals.split(',');
|
||||
for (int i=0; i<BANDS && i<parts.size(); i++) {
|
||||
bool ok=false;
|
||||
float db = parts[i].trimmed().toFloat(&ok);
|
||||
if (ok) {
|
||||
QSignalBlocker blk(m_sliders[i]);
|
||||
m_sliders[i]->setValue(qRound(db*10));
|
||||
m_engine->setEqBand(i, db);
|
||||
m_dbLabels[i]->setText(QString("%1%2 dB")
|
||||
.arg(db>=0?"+":"").arg(db,0,'f',0));
|
||||
}
|
||||
}
|
||||
m_curve->update();
|
||||
}
|
||||
|
||||
void EqualizerWidget::loadState() {
|
||||
QFile f(stateFile());
|
||||
if (!f.open(QIODevice::ReadOnly)) return;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||
if (!doc.isObject()) return;
|
||||
QJsonObject obj = doc.object();
|
||||
if (!obj.contains("bands")) return;
|
||||
QString vals = obj["bands"].toString();
|
||||
QStringList parts = vals.split(',');
|
||||
for (int i=0; i<BANDS && i<parts.size(); i++) {
|
||||
bool ok=false;
|
||||
int raw = parts[i].trimmed().toInt(&ok);
|
||||
if (ok) {
|
||||
QSignalBlocker blk(m_sliders[i]);
|
||||
m_sliders[i]->setValue(raw);
|
||||
float db = raw/10.f;
|
||||
m_engine->setEqBand(i, db);
|
||||
m_dbLabels[i]->setText(QString("%1%2 dB")
|
||||
.arg(db>=0?"+":"").arg(db,0,'f',0));
|
||||
}
|
||||
}
|
||||
m_curve->update();
|
||||
}
|
||||
|
||||
void EqualizerWidget::FreqCurveWidget::paintEvent(QPaintEvent *) {
|
||||
if (!m_sliders) return;
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
int w = width(), h = height();
|
||||
const int labelArea = 20;
|
||||
int graphH = h - labelArea;
|
||||
|
||||
p.fillRect(rect(), palette().base());
|
||||
|
||||
float logMin = std::log10(20.f);
|
||||
float logMax = std::log10(20000.f);
|
||||
auto freqToX = [&](float f) -> float {
|
||||
return (std::log10(f) - logMin) / (logMax - logMin) * w;
|
||||
};
|
||||
|
||||
// 0 dB line
|
||||
float midY = graphH / 2.f;
|
||||
p.setPen(QColor(180, 180, 180));
|
||||
p.drawLine(0, int(midY), w, int(midY));
|
||||
|
||||
// Draw frequency axis ticks and labels
|
||||
const float tickFreqs[] = {20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000};
|
||||
QFont tickFont = font();
|
||||
tickFont.setPointSizeF(7);
|
||||
p.setFont(tickFont);
|
||||
p.setPen(palette().windowText().color());
|
||||
for (float fq : tickFreqs) {
|
||||
float x = freqToX(fq);
|
||||
if (x < 0 || x > w) continue;
|
||||
// Tick mark
|
||||
p.drawLine(QPointF(x, graphH), QPointF(x, graphH + 3));
|
||||
// Label
|
||||
QString label;
|
||||
if (fq >= 1000) label = QString::number(fq / 1000, 'f', 0) + "k";
|
||||
else label = QString::number(fq, 'f', 0);
|
||||
QRectF textRect(x - 20, graphH + 3, 40, labelArea - 3);
|
||||
p.drawText(textRect, Qt::AlignHCenter | Qt::AlignTop, label);
|
||||
}
|
||||
|
||||
// Draw EQ curve
|
||||
QPainterPath path;
|
||||
for (int i = 0; i < w; ++i) {
|
||||
float f = std::pow(10.f, logMin + (logMax - logMin) * i / w);
|
||||
double totalDb = 0.0;
|
||||
for (int b = 0; b < BANDS; ++b) {
|
||||
float db = m_sliders[b]->value() / 10.f;
|
||||
if (std::abs(db) < 0.01f) continue;
|
||||
float f0 = AudioEngine::EQ_FREQS[b], Q = 1.41421356f;
|
||||
double ratio = f / f0;
|
||||
double lrSq = (ratio - 1.0 / ratio) * (ratio - 1.0 / ratio);
|
||||
double denom = 1.0 + lrSq / (Q * Q * (ratio + 1.0 / ratio) * (ratio + 1.0 / ratio));
|
||||
totalDb += db * denom;
|
||||
}
|
||||
float py = midY - float(totalDb / 12.0 * graphH / 2 * 0.9);
|
||||
py = std::clamp(py, 0.f, float(graphH));
|
||||
if (i == 0) path.moveTo(i, py);
|
||||
else path.lineTo(i, py);
|
||||
}
|
||||
p.setPen(QPen(palette().windowText().color(), 1.5));
|
||||
p.drawPath(path);
|
||||
|
||||
// Draw control points (band handles)
|
||||
p.setPen(Qt::NoPen);
|
||||
p.setBrush(QColor(60, 120, 255));
|
||||
for (int b = 0; b < BANDS; ++b) {
|
||||
float f0 = AudioEngine::EQ_FREQS[b];
|
||||
float db = m_sliders[b]->value() / 10.f;
|
||||
float nx = freqToX(f0);
|
||||
float ny = midY - (db / 12.f * graphH / 2 * 0.9f);
|
||||
ny = std::clamp(ny, 0.f, float(graphH));
|
||||
p.drawEllipse(QPointF(nx, ny), 3, 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <QComboBox>
|
||||
#include <QSlider>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include "AudioEngine.h"
|
||||
|
||||
class EqualizerWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
static constexpr int BANDS = AudioEngine::EQ_BANDS;
|
||||
|
||||
public:
|
||||
explicit EqualizerWidget(AudioEngine *engine, QWidget *parent = nullptr);
|
||||
void saveState();
|
||||
|
||||
private:
|
||||
void loadPresets();
|
||||
void savePresets();
|
||||
void populatePresetBox();
|
||||
void applyPresetAt(int idx);
|
||||
void loadState();
|
||||
|
||||
AudioEngine *m_engine;
|
||||
QComboBox *m_presetBox;
|
||||
QSlider *m_sliders[BANDS] {};
|
||||
QLabel *m_dbLabels[BANDS] {};
|
||||
QVector<QPair<QString,QString>> m_userPresets;
|
||||
|
||||
class FreqCurveWidget : public QWidget {
|
||||
public:
|
||||
explicit FreqCurveWidget(QWidget *p) : QWidget(p) {}
|
||||
void setSliders(QSlider *sliders[BANDS]) { m_sliders = sliders; }
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
private:
|
||||
QSlider **m_sliders { nullptr };
|
||||
} *m_curve;
|
||||
|
||||
static QString presetFile();
|
||||
static QString stateFile();
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "GradientDialog.h"
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QColorDialog>
|
||||
#include <QLabel>
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
|
||||
GradientDialog::GradientDialog(const QVector<QColor> &stops,
|
||||
QWidget *parent)
|
||||
: QDialog(parent), m_stops(stops)
|
||||
{
|
||||
setWindowTitle("Bar Colour Gradient");
|
||||
setMinimumSize(420, 180);
|
||||
|
||||
auto *mainLay = new QVBoxLayout(this);
|
||||
|
||||
// Preview
|
||||
m_preview = new QLabel;
|
||||
m_preview->setFixedHeight(40);
|
||||
m_preview->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||
mainLay->addWidget(m_preview);
|
||||
|
||||
// Colour stop buttons
|
||||
auto *btnLay = new QHBoxLayout;
|
||||
QStringList labels = { "Low", "Mid", "High", "Peak" };
|
||||
for (int i = 0; i < m_stops.size(); ++i) {
|
||||
auto *btn = new QPushButton(labels.value(i, QString::number(i)));
|
||||
btn->setFixedSize(70, 30);
|
||||
connect(btn, &QPushButton::clicked, this, [this, i]{ onStopClicked(i); });
|
||||
btnLay->addWidget(btn);
|
||||
m_buttons.append(btn);
|
||||
}
|
||||
mainLay->addLayout(btnLay);
|
||||
|
||||
// Action buttons
|
||||
auto *dialogBtns = new QHBoxLayout;
|
||||
auto *resetBtn = new QPushButton("Reset to Default");
|
||||
connect(resetBtn, &QPushButton::clicked, this, &GradientDialog::onResetToDefault);
|
||||
dialogBtns->addWidget(resetBtn);
|
||||
dialogBtns->addStretch();
|
||||
auto *ok = new QPushButton("Apply");
|
||||
connect(ok, &QPushButton::clicked, this, &QDialog::accept);
|
||||
dialogBtns->addWidget(ok);
|
||||
mainLay->addLayout(dialogBtns);
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
void GradientDialog::onStopClicked(int index)
|
||||
{
|
||||
QColor c = QColorDialog::getColor(m_stops[index], this,
|
||||
QString("Choose %1 colour").arg(index));
|
||||
if (c.isValid()) {
|
||||
m_stops[index] = c;
|
||||
updatePreview();
|
||||
}
|
||||
}
|
||||
|
||||
void GradientDialog::onResetToDefault()
|
||||
{
|
||||
m_stops = {
|
||||
QColor("#078D70"), // Low
|
||||
QColor("#26CEAA"), // Mid
|
||||
QColor("#98E8C1"), // High
|
||||
QColor("#FFFFFF") // Peak
|
||||
};
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
void GradientDialog::updatePreview()
|
||||
{
|
||||
QPixmap pix(m_preview->size());
|
||||
if (pix.width() == 0) pix = QPixmap(400, 40); // fallback size
|
||||
pix.fill(Qt::transparent);
|
||||
QPainter p(&pix);
|
||||
QLinearGradient grad(0, 0, pix.width(), 0);
|
||||
for (int i = 0; i < m_stops.size(); ++i) {
|
||||
qreal pos = (m_stops.size() == 1) ? 0.5
|
||||
: qreal(i) / (m_stops.size() - 1);
|
||||
grad.setColorAt(pos, m_stops[i]);
|
||||
}
|
||||
p.setBrush(grad);
|
||||
p.drawRect(pix.rect());
|
||||
p.end();
|
||||
m_preview->setPixmap(pix);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#ifndef GRADIENTDIALOG_H
|
||||
#define GRADIENTDIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QColor>
|
||||
#include <QVector>
|
||||
|
||||
class QPushButton;
|
||||
class QLabel;
|
||||
|
||||
class GradientDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit GradientDialog(const QVector<QColor> &stops,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
QVector<QColor> stops() const { return m_stops; }
|
||||
|
||||
private slots:
|
||||
void onStopClicked(int index);
|
||||
void onResetToDefault();
|
||||
|
||||
private:
|
||||
QVector<QColor> m_stops;
|
||||
QVector<QPushButton*> m_buttons;
|
||||
QLabel *m_preview;
|
||||
void updatePreview();
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,419 @@
|
||||
#include "MainWindow.h"
|
||||
#include "AudioEngine.h"
|
||||
#include "engine/MusicLibrary.h"
|
||||
#include "PlaylistModel.h"
|
||||
#include "TrackDelegate.h"
|
||||
#include "CoverArtWidget.h"
|
||||
#include "SpectrogramWidget.h"
|
||||
#include "EqualizerWidget.h"
|
||||
#include "Config.h"
|
||||
#include "PlaylistCreatorDialog.h"
|
||||
#include "PlaylistBrowserDialog.h"
|
||||
#include "PlaylistManager.h"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMenuBar>
|
||||
#include <QAction>
|
||||
#include <QFileDialog>
|
||||
#include <QMessageBox>
|
||||
#include <QInputDialog>
|
||||
#include <QCloseEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QRandomGenerator>
|
||||
#include <QDir>
|
||||
#include <QApplication>
|
||||
|
||||
MainWindow::MainWindow(QWidget *parent)
|
||||
: QMainWindow(parent)
|
||||
{
|
||||
setWindowTitle("SubWave");
|
||||
setMinimumSize(700, 760);
|
||||
|
||||
m_engine = new AudioEngine(this);
|
||||
m_library = new engine::MusicLibrary(this);
|
||||
m_model = new PlaylistModel(this);
|
||||
m_library->setRootPath(Config::loadMusicRoot());
|
||||
|
||||
buildUI();
|
||||
wireSignals();
|
||||
|
||||
m_uiTimer.setInterval(500);
|
||||
connect(&m_uiTimer, &QTimer::timeout, this, &MainWindow::onUiTimer);
|
||||
m_uiTimer.start();
|
||||
|
||||
reloadLibrary();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow()
|
||||
{
|
||||
m_eqWidget->saveState();
|
||||
m_engine->stop();
|
||||
m_library->shutdown();
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *e)
|
||||
{
|
||||
m_eqWidget->saveState();
|
||||
m_engine->stop();
|
||||
m_library->shutdown();
|
||||
e->accept();
|
||||
}
|
||||
|
||||
// ── UI Construction ─────────────────────────────────────────────────────────
|
||||
|
||||
void MainWindow::buildUI()
|
||||
{
|
||||
auto *central = new QWidget;
|
||||
setCentralWidget(central);
|
||||
auto *mainVlay = new QVBoxLayout(central);
|
||||
mainVlay->setSpacing(4);
|
||||
mainVlay->setContentsMargins(4, 4, 4, 4);
|
||||
|
||||
mainVlay->addWidget(buildNowPlayingPanel());
|
||||
|
||||
m_spectrogram = new SpectrogramWidget;
|
||||
mainVlay->addWidget(m_spectrogram, 1);
|
||||
|
||||
mainVlay->addWidget(buildBottomTabs());
|
||||
|
||||
buildMenuBar();
|
||||
}
|
||||
|
||||
QGroupBox *MainWindow::buildNowPlayingPanel()
|
||||
{
|
||||
auto *box = new QGroupBox("Now Playing");
|
||||
auto *hlay = new QHBoxLayout(box);
|
||||
|
||||
m_coverWidget = new CoverArtWidget;
|
||||
m_coverWidget->setFixedSize(130, 130);
|
||||
hlay->addWidget(m_coverWidget);
|
||||
|
||||
auto *right = new QVBoxLayout;
|
||||
right->setSpacing(4);
|
||||
|
||||
QFont boldF = m_lblTitle->font();
|
||||
boldF.setBold(true);
|
||||
boldF.setPointSizeF(11);
|
||||
m_lblTitle->setFont(boldF);
|
||||
right->addWidget(m_lblTitle);
|
||||
right->addWidget(m_lblArtist);
|
||||
right->addWidget(m_lblAlbum);
|
||||
|
||||
// Seek row
|
||||
auto *seekRow = new QHBoxLayout;
|
||||
m_seekBar->setRange(0, 1000);
|
||||
m_seekBar->setValue(0);
|
||||
seekRow->addWidget(m_seekBar);
|
||||
seekRow->addWidget(m_lblTime);
|
||||
right->addLayout(seekRow);
|
||||
|
||||
right->addLayout(buildTransportRow());
|
||||
hlay->addLayout(right, 1);
|
||||
return box;
|
||||
}
|
||||
|
||||
QHBoxLayout *MainWindow::buildTransportRow()
|
||||
{
|
||||
auto *row = new QHBoxLayout;
|
||||
auto *btns = new QHBoxLayout;
|
||||
btns->setSpacing(3);
|
||||
btns->addWidget(m_btnPrev);
|
||||
btns->addWidget(m_btnPlay);
|
||||
btns->addWidget(m_btnNext);
|
||||
btns->addSpacing(8);
|
||||
btns->addWidget(m_chkShuffle);
|
||||
btns->addWidget(m_chkRepeat);
|
||||
row->addLayout(btns);
|
||||
row->addStretch();
|
||||
|
||||
row->addWidget(new QLabel("Volume:"));
|
||||
m_volSlider->setRange(0, 100);
|
||||
m_volSlider->setValue(80);
|
||||
m_volSlider->setFixedWidth(90);
|
||||
row->addWidget(m_volSlider);
|
||||
return row;
|
||||
}
|
||||
|
||||
QTabWidget *MainWindow::buildBottomTabs()
|
||||
{
|
||||
auto *tabs = new QTabWidget;
|
||||
|
||||
// Playlist tab
|
||||
auto *plPanel = new QWidget;
|
||||
auto *plVlay = new QVBoxLayout(plPanel);
|
||||
plVlay->setContentsMargins(0, 2, 0, 0);
|
||||
|
||||
m_playlistView = new QListView;
|
||||
m_playlistView->setModel(m_model);
|
||||
m_playlistView->setItemDelegate(new TrackDelegate(this));
|
||||
m_playlistView->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
m_playlistView->setUniformItemSizes(true);
|
||||
m_playlistView->setAlternatingRowColors(true);
|
||||
m_playlistView->setMinimumHeight(220);
|
||||
plVlay->addWidget(m_playlistView);
|
||||
plVlay->addWidget(m_lblStatus);
|
||||
tabs->addTab(plPanel, "Playlist");
|
||||
|
||||
// EQ tab
|
||||
m_eqWidget = new EqualizerWidget(m_engine);
|
||||
tabs->addTab(m_eqWidget, "Equalizer");
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
void MainWindow::buildMenuBar()
|
||||
{
|
||||
auto *mb = menuBar();
|
||||
auto *file = mb->addMenu("&File");
|
||||
|
||||
auto *chooseAct = file->addAction("Choose Music Location…");
|
||||
chooseAct->setShortcut(QKeySequence::Open);
|
||||
connect(chooseAct, &QAction::triggered, this, [this]{
|
||||
QString dir = QFileDialog::getExistingDirectory(
|
||||
this, "Select Music Folder",
|
||||
m_library->rootPath(),
|
||||
QFileDialog::ShowDirsOnly);
|
||||
if (dir.isEmpty()) return;
|
||||
m_library->setRootPath(dir);
|
||||
Config::saveMusicRoot(dir);
|
||||
setWindowTitle("SubWave - " + QDir(dir).dirName());
|
||||
reloadLibrary();
|
||||
});
|
||||
|
||||
auto *refreshAct = file->addAction("Refresh Library");
|
||||
refreshAct->setShortcut(Qt::Key_F5);
|
||||
connect(refreshAct, &QAction::triggered, this, &MainWindow::reloadLibrary);
|
||||
|
||||
file->addSeparator();
|
||||
auto *quitAct = file->addAction("Quit");
|
||||
quitAct->setShortcut(QKeySequence::Quit);
|
||||
connect(quitAct, &QAction::triggered, qApp, &QApplication::quit);
|
||||
|
||||
// Playlists menu
|
||||
auto *playlists = mb->addMenu("&Playlists");
|
||||
|
||||
auto *newPlaylistAct = playlists->addAction("Create Playlist…");
|
||||
connect(newPlaylistAct, &QAction::triggered, this, &MainWindow::onCreatePlaylist);
|
||||
|
||||
auto *loadPlaylistAct = playlists->addAction("Load Playlist…");
|
||||
connect(loadPlaylistAct, &QAction::triggered, this, &MainWindow::onLoadPlaylist);
|
||||
|
||||
auto *help = mb->addMenu("&Help");
|
||||
auto *about = help->addAction("About SubWave");
|
||||
connect(about, &QAction::triggered, this, [this]{
|
||||
QMessageBox::about(this, "About SubWave",
|
||||
"SubWave - Qt6/FFmpeg music player\n\n"
|
||||
"Supports MP3, FLAC, WAV, OGG, AAC, M4A, Opus, WMA.\n"
|
||||
"Features a 10-band parametric EQ with user presets,\n"
|
||||
"real-time spectrogram & waterfall display, shuffle,\n"
|
||||
"repeat, and persistent library + EQ state.\n\n"
|
||||
"Config: ~/.subwave/");
|
||||
});
|
||||
}
|
||||
|
||||
// ── Signal/Slot Wiring ──────────────────────────────────────────────────────
|
||||
|
||||
void MainWindow::wireSignals()
|
||||
{
|
||||
// Transport buttons
|
||||
connect(m_btnPlay, &QPushButton::clicked, this, &MainWindow::togglePlayPause);
|
||||
/*connect(m_btnStop, &QPushButton::clicked, this, [this]{
|
||||
m_engine->stop();
|
||||
m_btnPlay->setText("Play");
|
||||
});*/
|
||||
connect(m_btnPrev, &QPushButton::clicked, this, &MainWindow::playPrev);
|
||||
connect(m_btnNext, &QPushButton::clicked, this, &MainWindow::playNext);
|
||||
connect(m_volSlider, &QSlider::valueChanged, this, [this](int v){
|
||||
m_engine->setVolume(v / 100.f);
|
||||
});
|
||||
|
||||
// Seek
|
||||
connect(m_seekBar, &QSlider::sliderPressed, this, [this]{ m_seekDragging = true; });
|
||||
connect(m_seekBar, &QSlider::sliderReleased, this, [this]{
|
||||
m_seekDragging = false;
|
||||
m_engine->seekFraction(m_seekBar->value() / 1000.0);
|
||||
});
|
||||
|
||||
// Double‑click to play
|
||||
connect(m_playlistView, &QListView::doubleClicked,
|
||||
this, [this](const QModelIndex &idx){ playTrack(idx.row()); });
|
||||
|
||||
// Engine callbacks
|
||||
connect(m_engine, &AudioEngine::trackEnded,
|
||||
this, &MainWindow::playNext, Qt::QueuedConnection);
|
||||
|
||||
connect(m_engine, &AudioEngine::positionChanged,
|
||||
this, [this](qint64 posFrames){
|
||||
if (!m_seekDragging) {
|
||||
qint64 total = m_engine->totalFrames();
|
||||
if (total > 0)
|
||||
m_seekBar->setValue(int(posFrames * 1000 / total));
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
connect(m_engine, &AudioEngine::fftReady,
|
||||
m_spectrogram, &SpectrogramWidget::receiveFft, Qt::QueuedConnection);
|
||||
|
||||
connect(m_engine, &AudioEngine::durationKnown,
|
||||
this, [this](qint64 secs){
|
||||
if (m_currentIndex >= 0) {
|
||||
engine::Track *t = m_model->trackAt(m_currentIndex);
|
||||
if (t && t->durationSecs == 0) {
|
||||
t->durationSecs = secs;
|
||||
m_model->refreshRow(m_currentIndex);
|
||||
}
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
// Library scanning
|
||||
connect(m_library, &engine::MusicLibrary::scanStarted, this, [this]{
|
||||
m_model->setTracks({});
|
||||
m_lblStatus->setText("Scanning...");
|
||||
});
|
||||
connect(m_library, &engine::MusicLibrary::trackFound, this, [this](const engine::Track &t){
|
||||
m_model->appendTrack(t);
|
||||
m_lblStatus->setText(QString("%1 tracks found").arg(m_model->rowCount({})));
|
||||
}, Qt::QueuedConnection);
|
||||
connect(m_library, &engine::MusicLibrary::scanComplete, this, [this](int n){
|
||||
m_model->setTracks(m_library->tracks()); // sorted version
|
||||
m_lblStatus->setText(QString("%1 tracks.").arg(n));
|
||||
}, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
// ── Playback Control ────────────────────────────────────────────────────────
|
||||
|
||||
void MainWindow::reloadLibrary()
|
||||
{
|
||||
m_library->scan();
|
||||
}
|
||||
|
||||
void MainWindow::playTrack(int index)
|
||||
{
|
||||
engine::Track *t = m_model->trackAt(index);
|
||||
if (!t) return;
|
||||
m_currentIndex = index;
|
||||
m_model->setCurrentIndex(index);
|
||||
m_engine->play(*t);
|
||||
m_coverWidget->setTrack(t);
|
||||
m_lblTitle->setText(t->displayTitle());
|
||||
m_lblArtist->setText(t->artist);
|
||||
m_lblAlbum->setText(t->album + (t->year > 0 ? " (" + QString::number(t->year) + ")" : ""));
|
||||
m_seekBar->setValue(0);
|
||||
m_btnPlay->setText("Pause");
|
||||
m_spectrogram->reset();
|
||||
m_playlistView->scrollTo(m_model->index(index));
|
||||
}
|
||||
|
||||
void MainWindow::togglePlayPause()
|
||||
{
|
||||
if (m_engine->state() == AudioEngine::State::Stopped) {
|
||||
if (m_currentIndex < 0 && m_model->rowCount({}) > 0)
|
||||
playTrack(0);
|
||||
else if (m_currentIndex >= 0)
|
||||
playTrack(m_currentIndex);
|
||||
} else {
|
||||
m_engine->togglePause();
|
||||
bool paused = (m_engine->state() == AudioEngine::State::Paused);
|
||||
m_btnPlay->setText(paused ? "Play" : "Pause");
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::playNext()
|
||||
{
|
||||
int n = m_model->rowCount({});
|
||||
if (n == 0) return;
|
||||
if (m_chkRepeat->isChecked() && m_currentIndex >= 0) {
|
||||
playTrack(m_currentIndex);
|
||||
return;
|
||||
}
|
||||
int next = m_chkShuffle->isChecked()
|
||||
? QRandomGenerator::global()->bounded(n)
|
||||
: ((m_currentIndex + 1) % n);
|
||||
playTrack(next);
|
||||
}
|
||||
|
||||
void MainWindow::playPrev()
|
||||
{
|
||||
if (m_currentIndex <= 0) return;
|
||||
playTrack(m_currentIndex - 1);
|
||||
}
|
||||
|
||||
// ── UI Timer ────────────────────────────────────────────────────────────────
|
||||
|
||||
void MainWindow::onUiTimer()
|
||||
{
|
||||
auto fmt = [](qint64 s) -> QString {
|
||||
return QString("%1:%2").arg(s / 60).arg(s % 60, 2, 10, QChar('0'));
|
||||
};
|
||||
|
||||
float sr = float(m_engine->sampleRate());
|
||||
qint64 posF = m_engine->positionFrames();
|
||||
qint64 totF = m_engine->totalFrames();
|
||||
qint64 posSec = qint64(posF / sr);
|
||||
|
||||
engine::Track *currentTrack = (m_currentIndex >= 0) ? m_model->trackAt(m_currentIndex) : nullptr;
|
||||
qint64 totSec = totF > 0 ? qint64(totF / sr)
|
||||
: (currentTrack ? currentTrack->durationSecs : 0);
|
||||
|
||||
// Write back duration if we now know it
|
||||
if (totF > 0 && m_currentIndex >= 0) {
|
||||
engine::Track *t = m_model->trackAt(m_currentIndex);
|
||||
if (t && t->durationSecs == 0) {
|
||||
t->durationSecs = totSec;
|
||||
m_model->refreshRow(m_currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
m_lblTime->setText(fmt(posSec) + " / " + fmt(totSec));
|
||||
|
||||
if (m_engine->state() == AudioEngine::State::Stopped)
|
||||
m_btnPlay->setText("Play");
|
||||
}
|
||||
|
||||
// ── Playlist Management ─────────────────────────────────────────────────────
|
||||
|
||||
void MainWindow::onCreatePlaylist()
|
||||
{
|
||||
PlaylistCreatorDialog dialog(this);
|
||||
if (dialog.exec() == QDialog::Accepted) {
|
||||
// Playlist was created and saved
|
||||
// Optionally auto-load it
|
||||
auto playlist = dialog.getPlaylist();
|
||||
loadPlaylistIntoModel(playlist.name);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onLoadPlaylist()
|
||||
{
|
||||
PlaylistBrowserDialog dialog(this);
|
||||
if (dialog.exec() == QDialog::Accepted) {
|
||||
QString playlistName = dialog.getSelectedPlaylist();
|
||||
if (!playlistName.isEmpty()) {
|
||||
loadPlaylistIntoModel(playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::loadPlaylistIntoModel(const QString &playlistName)
|
||||
{
|
||||
auto playlist = PlaylistManager::loadPlaylist(playlistName);
|
||||
|
||||
if (playlist.tracks.isEmpty()) {
|
||||
QMessageBox::warning(this, "Empty Playlist",
|
||||
QString("Playlist \"%1\" is empty.").arg(playlistName));
|
||||
return;
|
||||
}
|
||||
|
||||
m_model->setTracks(playlist.tracks);
|
||||
m_lblStatus->setText(
|
||||
QString("Playlist: %1 (%2 tracks)")
|
||||
.arg(playlistName)
|
||||
.arg(playlist.tracks.size())
|
||||
);
|
||||
|
||||
// Reset playback state
|
||||
m_currentIndex = -1;
|
||||
m_engine->stop();
|
||||
m_btnPlay->setText("Play");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QSlider>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QCheckBox>
|
||||
#include <QListView>
|
||||
#include <QTimer>
|
||||
#include <QTabWidget>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
|
||||
// Forward declarations
|
||||
class AudioEngine;
|
||||
namespace engine {
|
||||
class MusicLibrary;
|
||||
}
|
||||
class PlaylistModel;
|
||||
class CoverArtWidget;
|
||||
class SpectrogramWidget;
|
||||
class EqualizerWidget;
|
||||
class PlaylistCreatorDialog;
|
||||
class PlaylistBrowserDialog;
|
||||
|
||||
class MainWindow : public QMainWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow(QWidget *parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *e) override;
|
||||
|
||||
private:
|
||||
void buildUI();
|
||||
QGroupBox *buildNowPlayingPanel();
|
||||
QHBoxLayout *buildTransportRow();
|
||||
QTabWidget *buildBottomTabs();
|
||||
void buildMenuBar();
|
||||
void wireSignals();
|
||||
|
||||
void reloadLibrary();
|
||||
void playTrack(int index);
|
||||
void togglePlayPause();
|
||||
void playNext();
|
||||
void playPrev();
|
||||
void onUiTimer();
|
||||
void onCreatePlaylist();
|
||||
void onLoadPlaylist();
|
||||
void loadPlaylistIntoModel(const QString &playlistName);
|
||||
|
||||
// Core
|
||||
AudioEngine *m_engine;
|
||||
engine::MusicLibrary *m_library;
|
||||
PlaylistModel *m_model;
|
||||
|
||||
// Transport – plain ASCII labels now
|
||||
QLabel *m_lblTitle = new QLabel("No track selected");
|
||||
QLabel *m_lblArtist = new QLabel(" ");
|
||||
QLabel *m_lblAlbum = new QLabel(" ");
|
||||
QLabel *m_lblTime = new QLabel("0:00 / 0:00");
|
||||
QSlider *m_seekBar = new QSlider(Qt::Horizontal);
|
||||
QPushButton *m_btnPrev = new QPushButton("|<");
|
||||
QPushButton *m_btnPlay = new QPushButton("> Play");
|
||||
QPushButton *m_btnNext = new QPushButton(">|");
|
||||
QCheckBox *m_chkShuffle = new QCheckBox("Shuffle");
|
||||
QCheckBox *m_chkRepeat = new QCheckBox("Repeat");
|
||||
QSlider *m_volSlider = new QSlider(Qt::Horizontal);
|
||||
|
||||
// Playlist
|
||||
QListView *m_playlistView;
|
||||
QLabel *m_lblStatus = new QLabel("No library loaded.");
|
||||
|
||||
// Panels
|
||||
CoverArtWidget *m_coverWidget;
|
||||
SpectrogramWidget *m_spectrogram;
|
||||
EqualizerWidget *m_eqWidget;
|
||||
|
||||
// State
|
||||
int m_currentIndex { -1 };
|
||||
bool m_seekDragging { false };
|
||||
QTimer m_uiTimer;
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
#include "SpectrogramWidget.h"
|
||||
#include "GradientDialog.h"
|
||||
#include <QTimer>
|
||||
#include <QSurfaceFormat>
|
||||
#include <QFile>
|
||||
#include <QMouseEvent>
|
||||
#include <QColorDialog>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QDir>
|
||||
#include <QStandardPaths>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
SpectrogramWidget::SpectrogramWidget(QWidget *parent)
|
||||
: QOpenGLWidget(parent)
|
||||
{
|
||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
||||
|
||||
QSurfaceFormat fmt;
|
||||
fmt.setVersion(3, 3);
|
||||
fmt.setProfile(QSurfaceFormat::CoreProfile);
|
||||
fmt.setSwapInterval(1);
|
||||
fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
|
||||
setFormat(fmt);
|
||||
|
||||
setMinimumSize(400, 200);
|
||||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
|
||||
m_barHeights.resize(BAR_COUNT);
|
||||
m_barPeaksVec.resize(BAR_COUNT);
|
||||
|
||||
loadBarColors();
|
||||
|
||||
auto *timer = new QTimer(this);
|
||||
connect(timer, &QTimer::timeout,
|
||||
this, QOverload<>::of(&SpectrogramWidget::update));
|
||||
timer->start(16);
|
||||
}
|
||||
|
||||
SpectrogramWidget::~SpectrogramWidget()
|
||||
{
|
||||
makeCurrent();
|
||||
glDeleteTextures(1, &m_heatmapTex);
|
||||
glDeleteVertexArrays(1, &m_vaoBars);
|
||||
doneCurrent();
|
||||
}
|
||||
|
||||
void SpectrogramWidget::receiveFft(const QVector<float> &mag)
|
||||
{
|
||||
QMutexLocker lk(&m_queueMtx);
|
||||
while (m_fftQueue.size() >= 4)
|
||||
m_fftQueue.dequeue();
|
||||
m_fftQueue.enqueue(mag);
|
||||
update();
|
||||
}
|
||||
|
||||
void SpectrogramWidget::reset()
|
||||
{
|
||||
{
|
||||
QMutexLocker lk(&m_queueMtx);
|
||||
m_fftQueue.clear();
|
||||
}
|
||||
m_currentMag .fill(0.f);
|
||||
m_barSmoothed.fill(0.f);
|
||||
m_barPeak .fill(0.f);
|
||||
m_peakTimer .fill(0);
|
||||
update();
|
||||
}
|
||||
|
||||
void SpectrogramWidget::initializeGL()
|
||||
{
|
||||
initializeOpenGLFunctions();
|
||||
initShaders();
|
||||
buildHeatmapTexture();
|
||||
|
||||
glGenVertexArrays(1, &m_vaoBars);
|
||||
glDisable(GL_BLEND);
|
||||
}
|
||||
|
||||
void SpectrogramWidget::initShaders()
|
||||
{
|
||||
auto load = [](const QString &path) -> QByteArray {
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
qWarning() << "Cannot open shader:" << path;
|
||||
return {};
|
||||
}
|
||||
return f.readAll();
|
||||
};
|
||||
|
||||
if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Vertex, load(":/shaders/bars.vert")))
|
||||
qWarning() << "Bars vert error:" << m_barsProg.log();
|
||||
if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Fragment, load(":/shaders/bars.frag")))
|
||||
qWarning() << "Bars frag error:" << m_barsProg.log();
|
||||
if (!m_barsProg.link())
|
||||
qWarning() << "Bars link error:" << m_barsProg.log();
|
||||
|
||||
m_uBarHeights = m_barsProg.uniformLocation("barHeights");
|
||||
m_uBarPeaks = m_barsProg.uniformLocation("barPeaks");
|
||||
m_uBarCount = m_barsProg.uniformLocation("barCount");
|
||||
m_uBarWidth = m_barsProg.uniformLocation("barWidth");
|
||||
m_uGap = m_barsProg.uniformLocation("gap");
|
||||
m_uBottomY = m_barsProg.uniformLocation("bottomY");
|
||||
m_uTopClip = m_barsProg.uniformLocation("topClip");
|
||||
m_uHeatmap = m_barsProg.uniformLocation("heatmap");
|
||||
}
|
||||
|
||||
void SpectrogramWidget::buildHeatmapTexture()
|
||||
{
|
||||
updateHeatmapTexture(); // use current m_stops
|
||||
}
|
||||
|
||||
void SpectrogramWidget::updateHeatmapTexture()
|
||||
{
|
||||
if (m_heatmapTex) {
|
||||
glDeleteTextures(1, &m_heatmapTex);
|
||||
m_heatmapTex = 0;
|
||||
}
|
||||
|
||||
if (m_stops.size() < 2)
|
||||
m_stops = { QColor(0,0,0), QColor(255,255,255) };
|
||||
|
||||
const int nSegs = m_stops.size() - 1;
|
||||
|
||||
auto lerp = [](int x, int y, float f) {
|
||||
return std::clamp(int(x + (y - x) * f), 0, 255);
|
||||
};
|
||||
|
||||
for (int i = 0; i < 256; i++) {
|
||||
float t = float(i) / 255.f * nSegs;
|
||||
int seg = std::min(int(t), nSegs - 1);
|
||||
float f = t - seg;
|
||||
QColor a = m_stops[seg], b = m_stops[seg+1];
|
||||
m_heatmap[i] = qRgb(lerp(a.red(), b.red(), f),
|
||||
lerp(a.green(), b.green(), f),
|
||||
lerp(a.blue(), b.blue(), f));
|
||||
}
|
||||
|
||||
glGenTextures(1, &m_heatmapTex);
|
||||
glBindTexture(GL_TEXTURE_1D, m_heatmapTex);
|
||||
std::vector<GLubyte> td(256 * 3);
|
||||
for (int i = 0; i < 256; i++) {
|
||||
td[3*i+0] = quint8(qRed (m_heatmap[i]));
|
||||
td[3*i+1] = quint8(qGreen(m_heatmap[i]));
|
||||
td[3*i+2] = quint8(qBlue (m_heatmap[i]));
|
||||
}
|
||||
glTexImage1D(GL_TEXTURE_1D, 0, GL_RGB, 256, 0,
|
||||
GL_RGB, GL_UNSIGNED_BYTE, td.data());
|
||||
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
|
||||
void SpectrogramWidget::setStops(const QVector<QColor> &stops)
|
||||
{
|
||||
if (stops.size() < 2) return;
|
||||
m_stops = stops;
|
||||
makeCurrent();
|
||||
updateHeatmapTexture();
|
||||
doneCurrent();
|
||||
update();
|
||||
saveBarColors();
|
||||
}
|
||||
|
||||
void SpectrogramWidget::loadBarColors()
|
||||
{
|
||||
QString configDir = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/.subwave";
|
||||
QDir().mkpath(configDir);
|
||||
QString path = configDir + "/barcolors.json";
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return;
|
||||
|
||||
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||
f.close();
|
||||
if (!doc.isArray())
|
||||
return;
|
||||
|
||||
QJsonArray arr = doc.array();
|
||||
QVector<QColor> loaded;
|
||||
for (const auto &v : arr) {
|
||||
QJsonObject obj = v.toObject();
|
||||
int r = obj["r"].toInt(0);
|
||||
int g = obj["g"].toInt(0);
|
||||
int b = obj["b"].toInt(0);
|
||||
loaded.append(QColor(r, g, b));
|
||||
}
|
||||
if (loaded.size() >= 2)
|
||||
m_stops = loaded;
|
||||
}
|
||||
|
||||
void SpectrogramWidget::saveBarColors()
|
||||
{
|
||||
QString configDir = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/.subwave";
|
||||
QDir().mkpath(configDir);
|
||||
QString path = configDir + "/barcolors.json";
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::WriteOnly))
|
||||
return;
|
||||
|
||||
QJsonArray arr;
|
||||
for (const QColor &c : m_stops) {
|
||||
QJsonObject obj;
|
||||
obj["r"] = c.red();
|
||||
obj["g"] = c.green();
|
||||
obj["b"] = c.blue();
|
||||
arr.append(obj);
|
||||
}
|
||||
QJsonDocument doc(arr);
|
||||
f.write(doc.toJson());
|
||||
f.close();
|
||||
}
|
||||
|
||||
void SpectrogramWidget::mousePressEvent(QMouseEvent *e)
|
||||
{
|
||||
if (e->button() == Qt::LeftButton) {
|
||||
GradientDialog dlg(m_stops, this);
|
||||
if (dlg.exec() == QDialog::Accepted) {
|
||||
setStops(dlg.stops());
|
||||
}
|
||||
}
|
||||
QOpenGLWidget::mousePressEvent(e);
|
||||
}
|
||||
|
||||
void SpectrogramWidget::computeBars()
|
||||
{
|
||||
const int fftLen = m_currentMag.size();
|
||||
const double logMin = std::log10(20.0), logMax = std::log10(22050.0);
|
||||
QVector<float> bars(BAR_COUNT, 0.f);
|
||||
|
||||
for (int b = 0; b < BAR_COUNT; b++) {
|
||||
double f1 = std::pow(10.0, logMin + (logMax-logMin)* b /BAR_COUNT);
|
||||
double f2 = std::pow(10.0, logMin + (logMax-logMin)*(b+1)/BAR_COUNT);
|
||||
int bin1 = std::clamp(int(f1/22050.0*fftLen), 0, fftLen-1);
|
||||
int bin2 = std::clamp(int(f2/22050.0*fftLen), bin1, fftLen-1);
|
||||
float sum = 0.f; int cnt = 0;
|
||||
for (int k = bin1; k <= bin2; k++) { sum += m_currentMag[k]; cnt++; }
|
||||
bars[b] = cnt > 0 ? sum/cnt : 0.f;
|
||||
}
|
||||
for (int b = 0; b < BAR_COUNT; b++) {
|
||||
float db = bars[b] > 0 ? 20.f*std::log10(bars[b]) : DB_MIN;
|
||||
bars[b] = std::clamp((db - DB_MIN) / (DB_MAX - DB_MIN), 0.f, 1.f);
|
||||
}
|
||||
for (int b = 0; b < BAR_COUNT; b++) {
|
||||
m_barSmoothed[b] = m_barSmoothed[b]*0.65f + bars[b]*0.35f;
|
||||
if (m_barSmoothed[b] >= m_barPeak[b]) {
|
||||
m_barPeak[b] = m_barSmoothed[b];
|
||||
m_peakTimer[b] = 25;
|
||||
} else if (m_peakTimer[b] > 0) {
|
||||
m_peakTimer[b]--;
|
||||
} else {
|
||||
m_barPeak[b] = std::max(0.f, m_barPeak[b] - 0.006f);
|
||||
}
|
||||
m_barHeights [b] = m_barSmoothed[b];
|
||||
m_barPeaksVec[b] = m_barPeak[b];
|
||||
}
|
||||
}
|
||||
|
||||
void SpectrogramWidget::paintGL()
|
||||
{
|
||||
{
|
||||
QMutexLocker lk(&m_queueMtx);
|
||||
while (!m_fftQueue.isEmpty())
|
||||
m_currentMag = m_fftQueue.dequeue();
|
||||
}
|
||||
|
||||
computeBars();
|
||||
|
||||
QColor bg = palette().color(QPalette::Window);
|
||||
glClearColor(bg.redF(), bg.greenF(), bg.blueF(), 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
|
||||
if (m_barsProg.isLinked()) {
|
||||
const float barWidth = 2.0f / BAR_COUNT;
|
||||
const float gap = barWidth * 0.2f;
|
||||
const float drawWidth = barWidth - gap;
|
||||
|
||||
m_barsProg.bind();
|
||||
glUniform1fv(m_uBarHeights, BAR_COUNT, m_barHeights .constData());
|
||||
glUniform1fv(m_uBarPeaks, BAR_COUNT, m_barPeaksVec.constData());
|
||||
glUniform1i (m_uBarCount, BAR_COUNT);
|
||||
glUniform1f (m_uBarWidth, drawWidth);
|
||||
glUniform1f (m_uGap, gap);
|
||||
glUniform1f (m_uBottomY, -1.0f);
|
||||
glUniform1f (m_uTopClip, 1.0f);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_1D, m_heatmapTex);
|
||||
glUniform1i(m_uHeatmap, 0);
|
||||
glBindVertexArray(m_vaoBars);
|
||||
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, BAR_COUNT);
|
||||
glBindVertexArray(0);
|
||||
m_barsProg.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
#include <QOpenGLWidget>
|
||||
#include <QOpenGLFunctions_3_3_Core>
|
||||
#include <QOpenGLShaderProgram>
|
||||
#include <QMutex>
|
||||
#include <QQueue>
|
||||
#include <array>
|
||||
#include <QColor>
|
||||
#include <QVector>
|
||||
|
||||
class SpectrogramWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core
|
||||
{
|
||||
Q_OBJECT
|
||||
static constexpr int BAR_COUNT = 96;
|
||||
static constexpr float DB_MIN = -80.f, DB_MAX = 0.f;
|
||||
|
||||
public:
|
||||
explicit SpectrogramWidget(QWidget *parent = nullptr);
|
||||
~SpectrogramWidget();
|
||||
|
||||
public slots:
|
||||
void receiveFft(const QVector<float> &mag);
|
||||
void reset();
|
||||
|
||||
protected:
|
||||
void initializeGL() override;
|
||||
void paintGL() override;
|
||||
void mousePressEvent(QMouseEvent *e) override;
|
||||
|
||||
private:
|
||||
void initShaders();
|
||||
void buildHeatmapTexture();
|
||||
void updateHeatmapTexture();
|
||||
void setStops(const QVector<QColor> &stops);
|
||||
void loadBarColors();
|
||||
void saveBarColors();
|
||||
void computeBars();
|
||||
|
||||
QMutex m_queueMtx;
|
||||
QQueue<QVector<float>> m_fftQueue;
|
||||
QVector<float> m_currentMag = QVector<float>(2048, 0.f);
|
||||
|
||||
QVector<float> m_barSmoothed = QVector<float>(BAR_COUNT, 0.f);
|
||||
QVector<float> m_barPeak = QVector<float>(BAR_COUNT, 0.f);
|
||||
QVector<int> m_peakTimer = QVector<int> (BAR_COUNT, 0);
|
||||
QVector<float> m_barHeights;
|
||||
QVector<float> m_barPeaksVec;
|
||||
|
||||
QVector<QColor> m_stops = { QColor("#078D70"),
|
||||
QColor("#26CEAA"),
|
||||
QColor("#98E8C1"),
|
||||
QColor("#FFFFFF") };
|
||||
|
||||
std::array<QRgb, 256> m_heatmap{};
|
||||
|
||||
QOpenGLShaderProgram m_barsProg;
|
||||
GLuint m_heatmapTex = 0;
|
||||
GLuint m_vaoBars = 0;
|
||||
|
||||
int m_uBarHeights = -1;
|
||||
int m_uBarPeaks = -1;
|
||||
int m_uBarCount = -1;
|
||||
int m_uBarWidth = -1;
|
||||
int m_uGap = -1;
|
||||
int m_uBottomY = -1;
|
||||
int m_uTopClip = -1;
|
||||
int m_uHeatmap = -1;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "TrackDelegate.h"
|
||||
#include <QPainter>
|
||||
|
||||
TrackDelegate::TrackDelegate(QObject *parent)
|
||||
: QStyledItemDelegate(parent) {}
|
||||
|
||||
QSize TrackDelegate::sizeHint(const QStyleOptionViewItem &, const QModelIndex &) const {
|
||||
return {0, 42}; // fixed row height
|
||||
}
|
||||
|
||||
void TrackDelegate::paint(QPainter *p, const QStyleOptionViewItem &opt,
|
||||
const QModelIndex &idx) const
|
||||
{
|
||||
p->save();
|
||||
|
||||
bool selected = opt.state & QStyle::State_Selected;
|
||||
bool current = idx.data(Qt::UserRole + 1).toBool(); // is-playing flag
|
||||
QColor bg = selected
|
||||
? opt.palette.highlight().color()
|
||||
: (idx.row() % 2 == 0
|
||||
? opt.palette.base().color()
|
||||
: opt.palette.alternateBase().color());
|
||||
p->fillRect(opt.rect, bg);
|
||||
|
||||
if (current) {
|
||||
QColor accent = opt.palette.highlight().color();
|
||||
p->fillRect(opt.rect.left(), opt.rect.top(), 3, opt.rect.height(), accent);
|
||||
}
|
||||
|
||||
QColor fg = selected ? opt.palette.highlightedText().color()
|
||||
: opt.palette.text().color();
|
||||
QColor dim = selected ? fg : fg.lighter(160);
|
||||
|
||||
int pad = current ? 10 : 7;
|
||||
QRect r = opt.rect.adjusted(pad, 2, -6, -2);
|
||||
int mid = r.top() + r.height() / 2;
|
||||
|
||||
QFont fTop = opt.font;
|
||||
fTop.setBold(true);
|
||||
fTop.setPointSizeF(10);
|
||||
p->setFont(fTop);
|
||||
p->setPen(fg);
|
||||
QString title = idx.data(Qt::DisplayRole).toString();
|
||||
p->drawText(r.left(), r.top(), r.width(), mid - r.top(),
|
||||
Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, title);
|
||||
|
||||
QFont fBot = opt.font;
|
||||
fBot.setPointSizeF(8.5);
|
||||
p->setFont(fBot);
|
||||
p->setPen(dim);
|
||||
QString sub = idx.data(Qt::UserRole).toString();
|
||||
p->drawText(r.left(), mid, r.width(), r.bottom() - mid,
|
||||
Qt::AlignLeft | Qt::AlignVCenter | Qt::TextSingleLine, sub);
|
||||
|
||||
p->restore();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <QStyledItemDelegate>
|
||||
|
||||
class TrackDelegate : public QStyledItemDelegate {
|
||||
public:
|
||||
explicit TrackDelegate(QObject *parent = nullptr);
|
||||
QSize sizeHint(const QStyleOptionViewItem &, const QModelIndex &) const override;
|
||||
void paint(QPainter *p, const QStyleOptionViewItem &opt,
|
||||
const QModelIndex &idx) const override;
|
||||
};
|
||||
Reference in New Issue
Block a user