rather large commit, it now handles memory and threading properly.

---

more thorough commit message which was generated by the wonderful copilot AI tool:

Refactor and enhance audio encoding and decoding pipelines

- Introduced CodecUtils.h for utility functions related to audio codec selection, including sample format and bitrate clamping.
- Added DecoderPipeline.h to manage audio decoding, including opening files and retrieving audio stream information.
- Implemented EncoderPipeline.h to handle audio encoding, including encoder setup, resampling, and writing output files.
- Created OutputPathBuilder.h to generate output file paths based on reencode job specifications.
- Refactored SpectrogramWidget to improve OpenGL handling and ensure safe context usage.
- Updated ReencodeDialog.h to enhance code readability and maintainability.
- Improved error handling and logging throughout the audio processing components.
This commit is contained in:
2026-07-01 09:56:37 +02:00
parent 51421ffc2b
commit 1a00e98661
19 changed files with 984 additions and 1084 deletions
+64 -46
View File
@@ -23,6 +23,8 @@
#include <QKeySequence>
#include <QDir>
#include <QApplication>
#include <QCheckBox>
#include <QMetaObject>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
@@ -40,6 +42,8 @@ MainWindow::MainWindow(QWidget *parent)
m_uiTimer.setInterval(500);
connect(&m_uiTimer, &QTimer::timeout, this, [this] {
auto *e = m_playback->engine();
if (!e) return;
m_nowPlaying->updatePosition(
e->positionFrames(),
e->totalFrames(),
@@ -50,58 +54,75 @@ MainWindow::MainWindow(QWidget *parent)
});
m_uiTimer.start();
m_library->scan();
QMetaObject::invokeMethod(m_library, &LibraryController::scan,
Qt::QueuedConnection);
}
MainWindow::~MainWindow()
{
m_eqWidget->saveState();
m_playback->stop();
m_library->library()->shutdown();
shutdown();
}
void MainWindow::shutdown()
{
if (m_shutdown) return;
m_shutdown = true;
m_uiTimer.stop();
if (m_eqWidget)
m_eqWidget->saveState();
if (m_playback)
m_playback->stop();
if (m_library && m_library->library())
m_library->library()->shutdown();
}
void MainWindow::closeEvent(QCloseEvent *e)
{
m_eqWidget->saveState();
m_playback->stop();
m_library->library()->shutdown();
shutdown();
e->accept();
}
void MainWindow::buildUI()
{
auto *central = new QWidget;
auto *central = new QWidget(this);
auto *mainVlay = new QVBoxLayout(central);
mainVlay->setSpacing(4);
mainVlay->setContentsMargins(4, 4, 4, 4);
setCentralWidget(central);
m_nowPlaying = new NowPlayingPanel;
m_nowPlaying = new NowPlayingPanel(central);
mainVlay->addWidget(m_nowPlaying);
m_spectrogram = new SpectrogramWidget;
m_spectrogram = new SpectrogramWidget(central);
mainVlay->addWidget(m_spectrogram, 1);
auto *tabs = new QTabWidget;
auto *tabs = new QTabWidget(central);
auto *plPanel = new QWidget;
auto *plPanel = new QWidget(tabs);
auto *plVlay = new QVBoxLayout(plPanel);
plVlay->setContentsMargins(0, 2, 0, 0);
m_playlistView = new QListView;
m_playlistView = new QListView(plPanel);
m_playlistView->setModel(m_library->model());
m_playlistView->setItemDelegate(new TrackDelegate(this));
m_playlistView->setItemDelegate(new TrackDelegate(m_playlistView));
m_playlistView->setSelectionMode(QAbstractItemView::SingleSelection);
m_playlistView->setUniformItemSizes(true);
m_playlistView->setAlternatingRowColors(true);
m_playlistView->setMinimumHeight(220);
m_lblStatus = new QLabel("No library loaded.");
m_lblStatus = new QLabel("No library loaded.", plPanel);
plVlay->addWidget(m_playlistView);
plVlay->addWidget(m_lblStatus);
tabs->addTab(plPanel, "Playlist");
m_eqWidget = new EqualizerWidget(m_playback->engine());
auto *engine = m_playback->engine();
Q_ASSERT_X(engine, "MainWindow::buildUI",
"PlaybackController must provide a valid engine before buildUI");
m_eqWidget = new EqualizerWidget(engine, tabs);
tabs->addTab(m_eqWidget, "Equalizer");
mainVlay->addWidget(tabs);
@@ -117,13 +138,13 @@ void MainWindow::buildMenuBar()
auto *chooseAct = file->addAction("Choose Music Location...");
chooseAct->setShortcut(QKeySequence::Open);
connect(chooseAct, &QAction::triggered, this, [this] {
QString dir = QFileDialog::getExistingDirectory(
const QString dir = QFileDialog::getExistingDirectory(
this, "Select Music Folder",
m_library->library()->rootPath(),
QFileDialog::ShowDirsOnly);
if (dir.isEmpty()) return;
m_library->setRootPath(dir);
setWindowTitle("SubWave - " + QDir(dir).dirName());
setWindowTitle("SubWave " + QDir(dir).dirName());
m_library->scan();
});
@@ -133,10 +154,12 @@ void MainWindow::buildMenuBar()
file->addSeparator();
m_reencodeAct = file->addAction("Reencode Album");
m_reencodeAct->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R));
m_reencodeAct->setEnabled(false);
connect(m_reencodeAct, &QAction::triggered, this, &MainWindow::openReencodeDialog);
m_reencodeAct = file->addAction("Reencode Album\u2026");
m_reencodeAct->setShortcut(
QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R));
m_reencodeAct->setEnabled(false);
connect(m_reencodeAct, &QAction::triggered,
this, &MainWindow::openReencodeDialog);
file->addSeparator();
@@ -145,9 +168,9 @@ void MainWindow::buildMenuBar()
connect(quitAct, &QAction::triggered, qApp, &QApplication::quit);
auto *plMenu = mb->addMenu("&Playlists");
connect(plMenu->addAction("Create Playlist"), &QAction::triggered,
connect(plMenu->addAction("Create Playlist\u2026"), &QAction::triggered,
m_library, &LibraryController::createPlaylist);
connect(plMenu->addAction("Load Playlist"), &QAction::triggered,
connect(plMenu->addAction("Load Playlist\u2026"), &QAction::triggered,
m_library, &LibraryController::loadPlaylist);
auto *help = mb->addMenu("&Help");
@@ -155,8 +178,8 @@ void MainWindow::buildMenuBar()
connect(about, &QAction::triggered, this, [this] {
QMessageBox::about(this, "SubWave",
"SubWave\n"
"Its a music player.\n\n"
"Thats it.\n"
"It's a music player.\n\n"
"That's it.\n"
"~/.subwave/");
});
}
@@ -174,38 +197,32 @@ void MainWindow::wireSignals()
connect(m_nowPlaying, &NowPlayingPanel::volumeChanged,
m_playback, &PlaybackController::setVolume);
// shuffle / repeat checkboxes -> PlaybackController
connect(m_nowPlaying->shuffleCheckBox(), &QCheckBox::toggled,
m_playback, &PlaybackController::setShuffle);
connect(m_nowPlaying->repeatCheckBox(), &QCheckBox::toggled,
m_playback, &PlaybackController::setRepeat);
// PlaybackController -> NowPlayingPanel Spectrogram
connect(m_playback, &PlaybackController::playbackStarted,
m_nowPlaying, [this]{ m_nowPlaying->setPlaybackActive(true); });
m_nowPlaying, [this] { m_nowPlaying->setPlaybackActive(true); });
connect(m_playback, &PlaybackController::playbackPaused,
m_nowPlaying, [this]{ m_nowPlaying->setPlaybackActive(false); });
m_nowPlaying, [this] { m_nowPlaying->setPlaybackActive(false); });
connect(m_playback, &PlaybackController::playbackStopped,
m_nowPlaying, [this]{ m_nowPlaying->setPlaybackActive(false); });
m_nowPlaying, [this] { m_nowPlaying->setPlaybackActive(false); });
connect(m_playback, &PlaybackController::fftReady,
m_spectrogram, &SpectrogramWidget::receiveFft, Qt::QueuedConnection);
// PlaybackController -> LibraryController (resolve track index)
connect(m_playback, &PlaybackController::playRequested,
m_library, &LibraryController::onPlayRequested);
// LibraryController -> NowPlayingPanel (new track metadata)
connect(m_library, &LibraryController::trackChanged,
m_nowPlaying, [this](const engine::Track *t, int) {
m_nowPlaying->updateTrack(t);
m_spectrogram->reset();
m_playlistView->scrollTo(
m_library->model()->index(m_playback->currentIndex()));
m_reencodeAct->setEnabled(t != nullptr);
});
// LibraryController -> ui status / track count
connect(m_library, &LibraryController::statusMessage,
m_lblStatus, &QLabel::setText);
connect(m_library, &LibraryController::trackCountChanged,
@@ -232,18 +249,20 @@ void MainWindow::openReencodeDialog()
}
QString albumFilter;
int currentIdx = m_playback->currentIndex();
const int currentIdx = m_playback->currentIndex();
if (currentIdx >= 0 && currentIdx < all.size()) {
albumFilter = all[currentIdx].album;
} else {
QModelIndex sel = m_playlistView->currentIndex();
const QModelIndex sel = m_playlistView->currentIndex();
if (sel.isValid() && sel.row() < all.size())
albumFilter = all[sel.row()].album;
}
QList<engine::Track> targets;
if (!albumFilter.isEmpty() && albumFilter != "Unknown Album") {
const bool validAlbum =
!albumFilter.isEmpty() && albumFilter != "Unknown Album";
if (validAlbum) {
for (const auto &t : all)
if (t.album.compare(albumFilter, Qt::CaseInsensitive) == 0)
targets.append(t);
@@ -253,9 +272,8 @@ void MainWindow::openReencodeDialog()
targets = all;
ReencodeDialog dlg(targets, this);
if (!albumFilter.isEmpty() && albumFilter != "Unknown Album")
dlg.setWindowTitle(QString("Reencode Album — %1").arg(albumFilter));
if (validAlbum)
dlg.setWindowTitle(QString("Reencode Album \u2014 %1").arg(albumFilter));
dlg.exec();
}
@@ -269,11 +287,11 @@ void MainWindow::keyPressEvent(QKeyEvent *e)
case Qt::Key_Space:
m_playback->togglePlayPause(); e->accept(); return;
case Qt::Key_MediaNext:
m_playback->playNext(); e->accept(); return;
m_playback->playNext(); e->accept(); return;
case Qt::Key_MediaPrevious:
m_playback->playPrev(); e->accept(); return;
m_playback->playPrev(); e->accept(); return;
case Qt::Key_MediaStop:
m_playback->stop(); e->accept(); return;
m_playback->stop(); e->accept(); return;
default:
QMainWindow::keyPressEvent(e);
}
+11 -8
View File
@@ -1,4 +1,6 @@
// MainWindow.h
#pragma once
#include <QMainWindow>
#include <QTimer>
@@ -11,11 +13,13 @@ class QListView;
class QLabel;
class QAction;
class MainWindow : public QMainWindow {
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
~MainWindow() override;
protected:
void closeEvent(QCloseEvent *e) override;
@@ -28,17 +32,16 @@ private:
void buildUI();
void buildMenuBar();
void wireSignals();
void shutdown();
PlaybackController *m_playback { nullptr };
LibraryController *m_library { nullptr };
PlaybackController *m_playback { nullptr };
LibraryController *m_library { nullptr };
NowPlayingPanel *m_nowPlaying { nullptr };
SpectrogramWidget *m_spectrogram { nullptr };
EqualizerWidget *m_eqWidget { nullptr };
QListView *m_playlistView{ nullptr };
QLabel *m_lblStatus { nullptr };
QAction *m_reencodeAct { nullptr };
QTimer m_uiTimer;
QTimer m_uiTimer;
bool m_shutdown { false };
};
+70 -43
View File
@@ -1,5 +1,5 @@
#include "ReencodeDialog.h"
#include "engine/AlbumReencoder.h"
#include "engine/encoder/AlbumReencoder.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -15,10 +15,9 @@
#include <QFileDialog>
#include <QMessageBox>
#include <QApplication>
#include <QDir>
#include <QFileInfo>
enum Col { ColArtist=0, ColTitle, ColStatus, ColOutput, COL_COUNT };
enum Col { ColArtist = 0, ColTitle, ColStatus, ColOutput, COL_COUNT };
ReencodeDialog::ReencodeDialog(const QList<engine::Track> &tracks, QWidget *parent)
: QDialog(parent)
@@ -30,7 +29,6 @@ ReencodeDialog::ReencodeDialog(const QList<engine::Track> &tracks, QWidget *pare
setMinimumSize(820, 560);
buildUi();
populateTrackTable();
onPresetChanged(0);
connect(m_reencoder, &engine::AlbumReencoder::trackStarted,
this, &ReencodeDialog::onTrackStarted, Qt::QueuedConnection);
@@ -56,15 +54,21 @@ void ReencodeDialog::buildUi()
settingsGrid->setColumnStretch(1, 1);
settingsGrid->addWidget(new QLabel("Preset:", this), 0, 0);
m_presetCombo = new QComboBox(this);
for (const auto &p : m_presets)
m_presetCombo->addItem(p.label);
settingsGrid->addWidget(m_presetCombo, 0, 1, 1, 2);
m_presetCombo = new QComboBox(this);
// Create the description label BEFORE connecting signals and adding items
m_presetDesc = new QLabel(this);
m_presetDesc->setWordWrap(true);
m_presetDesc->setStyleSheet("color: palette(mid);");
settingsGrid->addWidget(m_presetDesc, 1, 1, 1, 2);
// Now connect the signal and add items (signal may fire during addItem)
connect(m_presetCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ReencodeDialog::onPresetChanged);
for (const auto &p : m_presets)
m_presetCombo->addItem(p.label);
settingsGrid->addWidget(m_presetCombo, 0, 1, 1, 2);
settingsGrid->addWidget(new QLabel("Output folder:", this), 2, 0);
m_outputDir = new QLineEdit(this);
@@ -77,6 +81,7 @@ void ReencodeDialog::buildUi()
root->addWidget(settingsGroup);
// track table
m_table = new QTableWidget(0, COL_COUNT, this);
m_table->setHorizontalHeaderLabels({"Artist", "Title", "Status", "Output"});
m_table->horizontalHeader()->setSectionResizeMode(ColArtist, QHeaderView::ResizeToContents);
@@ -89,6 +94,7 @@ void ReencodeDialog::buildUi()
m_table->setAlternatingRowColors(true);
root->addWidget(m_table, 1);
// Progress row
auto *progressLayout = new QHBoxLayout;
m_statusLabel = new QLabel("Ready", this);
m_statusLabel->setMinimumWidth(120);
@@ -100,6 +106,7 @@ void ReencodeDialog::buildUi()
progressLayout->addWidget(m_overallBar, 1);
root->addLayout(progressLayout);
// Button row
auto *btnLayout = new QHBoxLayout;
btnLayout->addStretch();
m_startStopBtn = new QPushButton("Start Encoding", this);
@@ -111,28 +118,28 @@ void ReencodeDialog::buildUi()
btnLayout->addWidget(m_closeBtn);
root->addLayout(btnLayout);
connect(m_presetCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ReencodeDialog::onPresetChanged);
connect(m_browseBtn, &QPushButton::clicked, this, &ReencodeDialog::onBrowseOutput);
connect(m_startStopBtn,&QPushButton::clicked, this, &ReencodeDialog::onStartStop);
connect(m_closeBtn, &QPushButton::clicked, this, &QDialog::reject);
connect(m_browseBtn, &QPushButton::clicked, this, &ReencodeDialog::onBrowseOutput);
connect(m_startStopBtn, &QPushButton::clicked, this, &ReencodeDialog::onStartStop);
connect(m_closeBtn, &QPushButton::clicked, this, &QDialog::reject);
}
void ReencodeDialog::populateTrackTable()
{
m_table->setRowCount(m_tracks.size());
m_table->setRowCount(static_cast<int>(m_tracks.size()));
for (int i = 0; i < m_tracks.size(); ++i) {
const auto &t = m_tracks[i];
m_table->setItem(i, ColArtist, new QTableWidgetItem(t.artist));
m_table->setItem(i, ColTitle, new QTableWidgetItem(t.displayTitle()));
m_table->setItem(i, ColStatus, new QTableWidgetItem("Pending"));
m_table->setItem(i, ColOutput, new QTableWidgetItem(""));
m_table->setItem(i, ColOutput, new QTableWidgetItem(QString{}));
}
}
void ReencodeDialog::onPresetChanged(int index)
{
if (index < 0 || index >= m_presets.size()) return;
if (index < 0 || index >= m_presets.size())
return;
m_presetDesc->setText(m_presets[index].description);
}
@@ -142,9 +149,10 @@ void ReencodeDialog::onBrowseOutput()
if (startDir.isEmpty() && !m_tracks.isEmpty())
startDir = QFileInfo(m_tracks.first().filePath).absolutePath();
QString dir = QFileDialog::getExistingDirectory(
const QString dir = QFileDialog::getExistingDirectory(
this, "Select Output Folder", startDir,
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
if (!dir.isEmpty())
m_outputDir->setText(dir);
}
@@ -158,16 +166,25 @@ void ReencodeDialog::onStartStop()
return;
}
int presetIdx = m_presetCombo->currentIndex();
if (presetIdx < 0) return;
if (m_tracks.isEmpty())
return;
const int presetIdx = m_presetCombo->currentIndex();
if (presetIdx < 0 || presetIdx >= m_presets.size())
return;
const engine::EncodePreset &preset = m_presets[presetIdx];
const QString outDir = m_outputDir->text().trimmed();
QString outDir = m_outputDir->text().trimmed();
// reset every row to pending
const auto pendingBrush = QApplication::palette().text();
for (int i = 0; i < m_tracks.size(); ++i) {
m_table->item(i, ColStatus)->setText("Pending");
m_table->item(i, ColStatus)->setForeground(QApplication::palette().text());
m_table->item(i, ColOutput)->setText("");
if (auto *st = m_table->item(i, ColStatus)) {
st->setText("Pending");
st->setForeground(pendingBrush);
}
if (auto *op = m_table->item(i, ColOutput))
op->setText(QString{});
}
QList<engine::ReencodeJob> jobs;
@@ -185,7 +202,7 @@ void ReencodeDialog::onStartStop()
m_reencoder->setJobs(jobs);
m_doneCount = 0;
m_totalCount = jobs.size();
m_overallBar->setRange(0, m_totalCount);
m_overallBar->setRange(0, static_cast<int>(m_totalCount));
m_overallBar->setValue(0);
setRunning(true);
@@ -194,14 +211,18 @@ void ReencodeDialog::onStartStop()
void ReencodeDialog::onTrackStarted(int index, const QString &title)
{
m_statusLabel->setText(QString("Encoding %1/%2")
.arg(index + 1).arg(m_totalCount));
m_statusLabel->setText(
QString("Encoding %1/%2 — %3")
.arg(index + 1)
.arg(m_totalCount)
.arg(title));
if (QTableWidgetItem *it = m_table->item(index, ColStatus)) {
it->setText("Encoding...");
}
m_table->scrollToItem(m_table->item(index, ColTitle));
Q_UNUSED(title)
if (auto *st = m_table->item(index, ColStatus))
st->setText("Encoding…");
// returns nullptr, gurad this shit
if (auto *cell = m_table->item(index, ColTitle))
m_table->scrollToItem(cell);
}
void ReencodeDialog::onTrackFinished(int index, bool ok,
@@ -211,15 +232,17 @@ void ReencodeDialog::onTrackFinished(int index, bool ok,
++m_doneCount;
updateOverallProgress();
if (QTableWidgetItem *st = m_table->item(index, ColStatus)) {
if (auto *st = m_table->item(index, ColStatus)) {
if (ok) {
st->setText("Done");
} else {
st->setText(QString("Failed: %1").arg(err));
st->setForeground(Qt::red);
}
}
if (ok) {
if (QTableWidgetItem *op = m_table->item(index, ColOutput))
if (auto *op = m_table->item(index, ColOutput))
op->setText(QFileInfo(outPath).fileName());
}
}
@@ -227,35 +250,39 @@ void ReencodeDialog::onTrackFinished(int index, bool ok,
void ReencodeDialog::onAllFinished(int succeeded, int failed)
{
setRunning(false);
m_doneCount = m_totalCount;
updateOverallProgress();
m_statusLabel->setText(
QString("Done - %1 succeeded, %2 failed").arg(succeeded).arg(failed));
m_overallBar->setValue(m_totalCount);
QString("Done %1 succeeded, %2 failed").arg(succeeded).arg(failed));
if (failed == 0) {
QMessageBox::information(this, "Reencode Complete",
QString("All %1 tracks encoded successfully.").arg(succeeded));
} else {
QMessageBox::warning(this, "Reencode Complete",
QString("%1 tracks succeeded, %2 failed.\n"
QString("%1 track(s) succeeded, %2 failed.\n"
"Check the Status column for details.")
.arg(succeeded).arg(failed));
.arg(succeeded).arg(failed));
}
}
void ReencodeDialog::setRunning(bool running)
{
m_presetCombo->setEnabled(!running);
m_outputDir->setEnabled(!running);
m_browseBtn->setEnabled(!running);
m_closeBtn->setEnabled(!running);
m_startStopBtn->setText(running ? "Stop" : "Start Encoding");
m_startStopBtn->setEnabled(true);
m_closeBtn->setEnabled(!running);
if (running)
m_statusLabel->setText("Starting…");
}
void ReencodeDialog::updateOverallProgress()
{
m_overallBar->setValue(m_doneCount);
m_overallBar->setFormat(
QString("%1 / %2").arg(m_doneCount).arg(m_totalCount));
const int done = static_cast<int>(m_doneCount);
const int total = static_cast<int>(m_totalCount);
m_overallBar->setValue(done);
m_overallBar->setFormat(QString("%1 / %2").arg(done).arg(total));
}
+22 -17
View File
@@ -1,6 +1,8 @@
#pragma once
#include <QDialog>
#include <QList>
#include "engine/Track.h"
#include "engine/encoder/ReencodeJob.h"
@@ -12,20 +14,23 @@ class QLabel;
class QProgressBar;
namespace engine { class AlbumReencoder; }
class ReencodeDialog : public QDialog {
class ReencodeDialog : public QDialog
{
Q_OBJECT
public:
explicit ReencodeDialog(const QList<engine::Track> &tracks,
QWidget *parent = nullptr);
~ReencodeDialog();
~ReencodeDialog() override;
private slots:
void onPresetChanged(int index);
void onBrowseOutput();
void onStartStop();
void onTrackStarted (int index, const QString &title);
void onTrackFinished(int index, bool ok, const QString &outPath,
const QString &err);
void onTrackFinished(int index, bool ok,
const QString &outPath, const QString &err);
void onAllFinished (int succeeded, int failed);
private:
@@ -34,21 +39,21 @@ private:
void setRunning(bool running);
void updateOverallProgress();
QList<engine::Track> m_tracks;
QList<engine::EncodePreset> m_presets;
QList<engine::Track> m_tracks;
QList<engine::EncodePreset> m_presets;
QComboBox *m_presetCombo { nullptr };
QLabel *m_presetDesc { nullptr };
QLineEdit *m_outputDir { nullptr };
QPushButton *m_browseBtn { nullptr };
QTableWidget *m_table { nullptr };
QProgressBar *m_overallBar { nullptr };
QLabel *m_statusLabel { nullptr };
QPushButton *m_startStopBtn { nullptr };
QPushButton *m_closeBtn { nullptr };
QComboBox *m_presetCombo { nullptr };
QLabel *m_presetDesc { nullptr };
QLineEdit *m_outputDir { nullptr };
QPushButton *m_browseBtn { nullptr };
QTableWidget *m_table { nullptr };
QProgressBar *m_overallBar { nullptr };
QLabel *m_statusLabel { nullptr };
QPushButton *m_startStopBtn { nullptr };
QPushButton *m_closeBtn { nullptr };
engine::AlbumReencoder *m_reencoder { nullptr };
int m_doneCount { 0 };
int m_totalCount { 0 };
qsizetype m_doneCount { 0 };
qsizetype m_totalCount { 0 };
};
+194 -97
View File
@@ -1,5 +1,7 @@
// SpectrogramWidget.cpp
#include "SpectrogramWidget.h"
#include "GradientDialog.h"
#include <QTimer>
#include <QSurfaceFormat>
#include <QFile>
@@ -11,6 +13,10 @@
#include <QStandardPaths>
#include <cmath>
// ---------------------------------------------------------------------------
// Construction / destruction
// ---------------------------------------------------------------------------
SpectrogramWidget::SpectrogramWidget(QWidget *parent)
: QOpenGLWidget(parent)
{
@@ -28,6 +34,8 @@ SpectrogramWidget::SpectrogramWidget(QWidget *parent)
loadBarColors();
// Qt won't invoke paintGL until after initializeGL completes,
// so starting the timer here is safe even before the context exists.
auto *timer = new QTimer(this);
connect(timer, &QTimer::timeout,
this, QOverload<>::of(&SpectrogramWidget::update));
@@ -38,17 +46,27 @@ SpectrogramWidget::SpectrogramWidget(QWidget *parent)
SpectrogramWidget::~SpectrogramWidget()
{
// FIX #1: if GL context creation failed (e.g. WSL / no GPU),
// initializeOpenGLFunctions() was never called, so glDelete* function
// pointers are null. Guard on m_glReady to avoid calling through them.
if (!m_glReady)
return;
makeCurrent();
glDeleteTextures(1, &m_heatmapTex);
glDeleteVertexArrays(1, &m_vaoBars);
doneCurrent();
}
// ---------------------------------------------------------------------------
// Public slots
// ---------------------------------------------------------------------------
void SpectrogramWidget::receiveFft(const QVector<float> &mag)
{
QMutexLocker lk(&m_latestMtx);
m_latestMag = mag;
m_newMagAvailable = true;
m_latestMag = mag;
m_newMagAvailable = true;
}
void SpectrogramWidget::reset()
@@ -58,23 +76,47 @@ void SpectrogramWidget::reset()
m_latestMag.clear();
m_newMagAvailable = false;
}
// Everything below is main-thread only — no additional lock needed.
m_currentMag.fill(0.f);
m_barSmoothed.fill(0.f);
m_barPeak.fill(0.f);
m_barSmoothed .fill(0.f);
m_barPeak .fill(0.f);
m_peakHoldTimer.fill(0.f);
m_dirty = false;
m_barHeights .fill(0.f);
m_barPeaksVec .fill(0.f);
update();
}
// ---------------------------------------------------------------------------
// GL lifecycle
// ---------------------------------------------------------------------------
void SpectrogramWidget::initializeGL()
{
initializeOpenGLFunctions();
// FIX #1 (cont): initializeOpenGLFunctions resolves all GL 3.3 function
// pointers. If it returns false (no suitable context — common in WSL
// without a real GPU), leave m_glReady false and bail out.
// Every subsequent GL call site checks m_glReady first.
if (!initializeOpenGLFunctions()) {
qWarning() << "[Spectrogram] OpenGL 3.3 Core not available — "
"rendering disabled (WSL/no GPU?)";
return;
}
initShaders();
updateHeatmapTexture();
// FIX #5: only proceed if shaders actually linked.
// initShaders() already printed a warning on failure.
if (!m_barsProg.isLinked()) {
qWarning() << "[Spectrogram] Shader program failed — rendering disabled";
return;
}
glGenVertexArrays(1, &m_vaoBars);
glDisable(GL_BLEND);
buildHeatmapTexture();
// Pre-compute the logarithmic frequency-to-bin mapping.
const int bins = engine::SpectrumAnalyzer::BIN_COUNT;
const double logMin = std::log10(20.0);
const double logMax = std::log10(22050.0);
@@ -83,30 +125,100 @@ void SpectrogramWidget::initializeGL()
for (int b = 0; b < BAR_COUNT; ++b) {
double f1 = std::pow(10.0, logMin + range * b / BAR_COUNT);
double f2 = std::pow(10.0, logMin + range * (b + 1) / BAR_COUNT);
m_binMap[b].lo = std::clamp(int(f1 / 22050.0 * bins), 0, bins - 1);
m_binMap[b].hi = std::clamp(int(f2 / 22050.0 * bins), m_binMap[b].lo, bins - 1);
int lo = std::clamp(int(f1 / 22050.0 * bins), 0, bins - 1);
int hi = std::clamp(int(f2 / 22050.0 * bins), lo, bins - 1);
m_binMap[b] = { lo, hi };
}
m_glReady = true; // all setup succeeded
}
void SpectrogramWidget::resizeGL(int w, int h)
{
// Viewport is managed by QOpenGLWidget automatically.
// Override present so Qt's virtual dispatch is explicit and future
// per-frame uniform updates (e.g. aspect ratio) have a home.
Q_UNUSED(w) Q_UNUSED(h)
}
void SpectrogramWidget::paintGL()
{
// FIX #3: GL functions are unresolved if context creation failed.
// Without this guard, glClear → null pointer → crash.
if (!m_glReady)
return;
const float dt = static_cast<float>(m_frameTimer.restart()) / 1000.f;
// Swap in any new FFT data from the audio thread.
{
QMutexLocker lk(&m_latestMtx);
if (m_newMagAvailable) {
m_currentMag = std::move(m_latestMag);
m_latestMag = {};
m_newMagAvailable = false;
}
}
// Always run computeBars even with no new data — peaks decay and
// smoothing converges continuously via the dt parameter.
computeBars(dt);
const QColor bg = palette().color(QPalette::Window);
glClearColor(bg.redF(), bg.greenF(), bg.blueF(), 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
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 .data());
glUniform1fv(m_uBarPeaks, BAR_COUNT, m_barPeaksVec.data());
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();
}
// ---------------------------------------------------------------------------
// Private: GL helpers
// ---------------------------------------------------------------------------
void SpectrogramWidget::initShaders()
{
auto load = [](const QString &path) -> QByteArray {
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
qWarning() << "Cannot open shader:" << path;
qWarning() << "[Spectrogram] 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();
const QByteArray vertSrc = load(":/shaders/bars.vert");
const QByteArray fragSrc = load(":/shaders/bars.frag");
// FIX #5: bail before trying to compile empty source
if (vertSrc.isEmpty() || fragSrc.isEmpty())
return;
if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Vertex, vertSrc))
qWarning() << "[Spectrogram] Vert error:" << m_barsProg.log();
if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Fragment, fragSrc))
qWarning() << "[Spectrogram] Frag error:" << m_barsProg.log();
if (!m_barsProg.link()) {
qWarning() << "[Spectrogram] Link error:" << m_barsProg.log();
return;
}
m_uBarHeights = m_barsProg.uniformLocation("barHeights");
m_uBarPeaks = m_barsProg.uniformLocation("barPeaks");
@@ -118,23 +230,27 @@ void SpectrogramWidget::initShaders()
m_uHeatmap = m_barsProg.uniformLocation("heatmap");
}
void SpectrogramWidget::updateHeatmapTexture()
void SpectrogramWidget::buildHeatmapTexture()
{
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) {
auto lerpCh = [](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));
float t = float(i) / 255.f * float(nSegs);
int seg = std::min(int(t), nSegs - 1);
float f = t - float(seg);
// FIX #8: renamed locals to ca/cb to avoid shadowing the
// outer QColor variable `b` from the original code
const QColor &ca = m_stops[seg];
const QColor &cb = m_stops[seg + 1];
m_heatmap[i] = qRgb(lerpCh(ca.red(), cb.red(), f),
lerpCh(ca.green(), cb.green(), f),
lerpCh(ca.blue(), cb.blue(), f));
}
std::array<GLubyte, 256 * 3> td;
@@ -157,30 +273,42 @@ void SpectrogramWidget::updateHeatmapTexture()
glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
}
// ---------------------------------------------------------------------------
// Private: gradient persistence
// ---------------------------------------------------------------------------
void SpectrogramWidget::setStops(const QVector<QColor> &stops)
{
if (stops.size() < 2) return;
if (stops.size() < 2)
return;
m_stops = stops;
makeCurrent();
updateHeatmapTexture();
doneCurrent();
if (m_glReady) {
// FIX #2: makeCurrent() can return false (no valid context).
// Only call GL functions if it succeeded.
makeCurrent();
buildHeatmapTexture();
doneCurrent();
}
update();
saveBarColors();
}
void SpectrogramWidget::loadBarColors()
{
QString path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation)
+ "/.subwave/barcolors.json";
const QString path =
QStandardPaths::writableLocation(QStandardPaths::HomeLocation)
+ "/.subwave/barcolors.json";
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) return;
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
if (!doc.isArray()) return;
QVector<QColor> loaded;
for (const auto &v : doc.array()) {
QJsonObject obj = v.toObject();
for (const QJsonValue &v : doc.array()) {
const QJsonObject obj = v.toObject();
loaded.append(QColor(obj["r"].toInt(), obj["g"].toInt(), obj["b"].toInt()));
}
if (loaded.size() >= 2)
@@ -189,10 +317,12 @@ void SpectrogramWidget::loadBarColors()
void SpectrogramWidget::saveBarColors()
{
QString dir = QStandardPaths::writableLocation(QStandardPaths::HomeLocation)
+ "/.subwave";
QDir().mkpath(dir);
QFile f(dir + "/barcolors.json");
const QString dirPath =
QStandardPaths::writableLocation(QStandardPaths::HomeLocation)
+ "/.subwave";
QDir().mkpath(dirPath);
QFile f(dirPath + "/barcolors.json");
if (!f.open(QIODevice::WriteOnly)) return;
QJsonArray arr;
@@ -206,21 +336,16 @@ void SpectrogramWidget::saveBarColors()
f.write(QJsonDocument(arr).toJson());
}
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);
}
// ---------------------------------------------------------------------------
// Private: bar computation
// ---------------------------------------------------------------------------
void SpectrogramWidget::computeBars(float dt)
{
// FIX #4: fftLen could be 0 if m_currentMag was somehow cleared;
// fftLen - 1 would wrap to INT_MAX as unsigned, or be -1 as signed.
const int fftLen = m_currentMag.size();
if (fftLen == 0) return;
for (int b = 0; b < BAR_COUNT; ++b) {
const int lo = m_binMap[b].lo;
@@ -228,14 +353,16 @@ void SpectrogramWidget::computeBars(float dt)
float sum = 0.f;
int cnt = 0;
for (int k = lo; k <= hi; ++k) { sum += m_currentMag[k]; ++cnt; }
float raw = cnt > 0 ? sum / cnt : 0.f;
for (int k = lo; k <= hi; ++k) {
sum += m_currentMag[k];
++cnt;
}
const float raw = cnt > 0 ? sum / float(cnt) : 0.f;
const float db = raw > 0.f ? 20.f * std::log10(raw) : DB_MIN;
const float bar = std::clamp((db - DB_MIN) / (DB_MAX - DB_MIN), 0.f, 1.f);
float db = raw > 0.f ? 20.f * std::log10(raw) : DB_MIN;
float bar = std::clamp((db - DB_MIN) / (DB_MAX - DB_MIN), 0.f, 1.f);
const float coeff = bar > m_barSmoothed[b] ? 0.50f : 0.18f;
m_barSmoothed[b] += (bar - m_barSmoothed[b]) * coeff;
const float coeff = bar > m_barSmoothed[b] ? 0.50f : 0.18f;
m_barSmoothed[b] += (bar - m_barSmoothed[b]) * coeff;
if (m_barSmoothed[b] >= m_barPeak[b]) {
m_barPeak[b] = m_barSmoothed[b];
@@ -251,46 +378,16 @@ void SpectrogramWidget::computeBars(float dt)
}
}
// ---------------------------------------------------------------------------
// Mouse
// ---------------------------------------------------------------------------
void SpectrogramWidget::paintGL()
void SpectrogramWidget::mousePressEvent(QMouseEvent *e)
{
const float dt = static_cast<float>(m_frameTimer.restart()) / 1000.f;
{
QMutexLocker lk(&m_latestMtx);
if (m_newMagAvailable) {
m_currentMag = std::move(m_latestMag);
m_latestMag = {};
m_newMagAvailable = false;
m_dirty = true;
}
if (e->button() == Qt::LeftButton) {
GradientDialog dlg(m_stops, this);
if (dlg.exec() == QDialog::Accepted)
setStops(dlg.stops());
}
computeBars(dt);
QColor bg = palette().color(QPalette::Window);
glClearColor(bg.redF(), bg.greenF(), bg.blueF(), 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
if (!m_barsProg.isLinked()) return;
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 .data());
glUniform1fv(m_uBarPeaks, BAR_COUNT, m_barPeaksVec.data());
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();
QOpenGLWidget::mousePressEvent(e);
}
+38 -41
View File
@@ -1,25 +1,25 @@
#pragma once
#include <QOpenGLWidget>
#include <QOpenGLFunctions_3_3_Core>
#include <QOpenGLShaderProgram>
#include <QElapsedTimer>
#include <QMutex>
#include <array>
#include <atomic>
#include <QColor>
#include <QVector>
#include "SpectrumAnalyzer.h"
class SpectrogramWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core
class SpectrogramWidget : public QOpenGLWidget,
protected QOpenGLFunctions_3_3_Core
{
Q_OBJECT
static constexpr int BAR_COUNT = 96;
static constexpr float DB_MIN = -80.f;
static constexpr float DB_MAX = 0.f;
static constexpr float PEAK_HOLD_SEC = 0.4f;
static constexpr float PEAK_DECAY_SEC = 0.5f;
static constexpr int BAR_COUNT = 96;
static constexpr float DB_MIN = -80.f;
static constexpr float DB_MAX = 0.f;
static constexpr float PEAK_HOLD_SEC = 0.4f;
static constexpr float PEAK_DECAY_SEC = 0.5f;
public:
explicit SpectrogramWidget(QWidget *parent = nullptr);
@@ -30,60 +30,57 @@ public slots:
void reset();
protected:
void initializeGL() override;
void paintGL() override;
void initializeGL() override;
void resizeGL(int w, int h) override;
void paintGL() override;
void mousePressEvent(QMouseEvent *e) override;
private:
// GL setup
void initShaders();
void updateHeatmapTexture();
void buildHeatmapTexture();
// Data
void setStops(const QVector<QColor> &stops);
void loadBarColors();
void saveBarColors();
void computeBars(float dt);
QMutex m_latestMtx;
QVector<float> m_latestMag; // written by audio thread
bool m_newMagAvailable{false};
QVector<float> m_latestMag;
bool m_newMagAvailable { false };
QVector<float> m_currentMag =
QVector<float>(engine::SpectrumAnalyzer::BIN_COUNT, 0.f);
// --- bar state ---
std::array<float, BAR_COUNT> m_barSmoothed{};
std::array<float, BAR_COUNT> m_barPeak{};
std::array<float, BAR_COUNT> m_peakHoldTimer{}; // seconds remaining in hold
// Uploaded to GPU each frame
std::array<float, BAR_COUNT> m_barHeights{};
std::array<float, BAR_COUNT> m_barPeaksVec{};
std::array<float, BAR_COUNT> m_barSmoothed {};
std::array<float, BAR_COUNT> m_barPeak {};
std::array<float, BAR_COUNT> m_peakHoldTimer {};
std::array<float, BAR_COUNT> m_barHeights {};
std::array<float, BAR_COUNT> m_barPeaksVec {};
struct BinRange { int lo, hi; };
std::array<BinRange, BAR_COUNT> m_binMap{};
std::array<BinRange, BAR_COUNT> m_binMap {};
QVector<QColor> m_stops = { QColor("#078D70"),
QColor("#26CEAA"),
QColor("#98E8C1"),
QColor("#FFFFFF") };
std::array<QRgb, 256> m_heatmap{};
QVector<QColor> m_stops = {
QColor("#078D70"), QColor("#26CEAA"),
QColor("#98E8C1"), QColor("#FFFFFF")
};
std::array<QRgb, 256> m_heatmap {};
QElapsedTimer m_frameTimer;
bool m_dirty{false}; // true when new data arrived since last paint
bool m_glReady { false };
QOpenGLShaderProgram m_barsProg;
GLuint m_heatmapTex = 0;
GLuint m_vaoBars = 0;
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;
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 };
};