initial commit

This commit is contained in:
2026-06-02 19:07:34 +02:00
commit 0d125993a5
25 changed files with 2016 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# C++ objects and libs
*.slo
*.lo
*.o
*.a
*.la
*.lai
*.so
*.so.*
*.dll
*.dylib
# Qt-es
object_script.*.Release
object_script.*.Debug
*_plugin_import.cpp
/.qmake.cache
/.qmake.stash
*.pro.user
*.pro.user.*
*.qbs.user
*.qbs.user.*
*.moc
moc_*.cpp
moc_*.h
qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
build/
*.qm
*.prl
*.qmlls.ini
# Qt unit tests
target_wrapper.*
# QtCreator
*.autosave
# QtCreator Qml
*.qmlproject.user
*.qmlproject.user.*
# QtCreator CMake
CMakeLists.txt.user*
# QtCreator 4.8< compilation database
compile_commands.json
# QtCreator local machine specific files for imported projects
*creator.user*
*_qmlcache.qrc
./build
+66
View File
@@ -0,0 +1,66 @@
cmake_minimum_required(VERSION 3.22)
project(SubWave LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# ── Find Qt6 ─────────────────────────────────────────────────────────────────
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia)
# ── Find FFmpeg via pkg-config ───────────────────────────────────────────────
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET
libavformat
libavcodec
libavutil
libswresample
)
# Auto-generate MOC files from Q_OBJECT headers
set(CMAKE_AUTOMOC ON)
# ── Sources (all located in src/ relative to this file) ──────────────────────
set(SOURCES
src/main.cpp
src/MetadataReader.cpp
src/Config.cpp
src/AudioEngine.cpp
src/MusicLibrary.cpp
src/CoverArtWidget.cpp
src/SpectrogramWidget.cpp
src/EqualizerWidget.cpp
src/TrackDelegate.cpp
src/PlaylistModel.cpp
src/MainWindow.cpp
)
set(HEADERS
src/Track.h
src/MetadataReader.h
src/Config.h
src/AudioEngine.h
src/MusicLibrary.h
src/CoverArtWidget.h
src/SpectrogramWidget.h
src/EqualizerWidget.h
src/TrackDelegate.h
src/PlaylistModel.h
src/MainWindow.h
)
add_executable(SubWave ${SOURCES} ${HEADERS})
# ── Link libraries ───────────────────────────────────────────────────────────
target_link_libraries(SubWave PRIVATE
Qt6::Widgets
Qt6::Multimedia
PkgConfig::FFMPEG
)
# ── (Optional) Copy FFmpeg shared libs next to the binary on Windows ─────────
if(WIN32)
add_custom_command(TARGET SubWave POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:PkgConfig::FFMPEG>" "$<TARGET_FILE_DIR:SubWave>"
)
endif()
+355
View File
@@ -0,0 +1,355 @@
#include "AudioEngine.h"
#include <cmath>
#include <numbers>
#include <algorithm>
#include <QThread>
#include <QMediaDevices>
#include <QAudioDevice>
#include <QAudioFormat>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libswresample/swresample.h>
}
AudioEngine::AudioEngine(QObject *parent) : QObject(parent) {
initEq();
}
AudioEngine::~AudioEngine() { stop(); }
AudioEngine::State AudioEngine::state() const { return m_state.load(); }
qint64 AudioEngine::positionFrames() const { return m_posFrames.load(); }
qint64 AudioEngine::totalFrames() const { return m_totalFrames.load(); }
int AudioEngine::sampleRate() const { return m_sampleRate.load(); }
void AudioEngine::setVolume(float v) {
m_volume = std::clamp(v, 0.f, 1.f);
if (m_sink) m_sink->setVolume(m_volume);
}
void AudioEngine::play(const Track &track) {
stop();
m_stopReq = false;
m_pauseReq = false;
m_track = track;
m_thread = QThread::create([this]{ playbackLoop(); });
m_thread->setObjectName("subwave-audio");
m_thread->start(QThread::HighestPriority);
}
void AudioEngine::togglePause() {
if (m_state == State::Playing) {
m_pauseReq = true;
m_state = State::Paused;
} else if (m_state == State::Paused) {
m_pauseReq = false;
m_state = State::Playing;
m_pauseCv.wakeAll();
}
}
void AudioEngine::stop() {
m_stopReq = true;
m_pauseReq = false;
m_pauseCv.wakeAll();
if (m_thread) {
m_thread->quit();
m_thread->wait(3000);
delete m_thread;
m_thread = nullptr;
}
m_state = State::Stopped;
m_posFrames = 0;
}
void AudioEngine::seekFraction(double fraction) {
m_seekFraction = std::clamp(fraction, 0.0, 1.0);
m_seekPending = true;
}
void AudioEngine::setEqBand(int band, float db) {
if (band < 0 || band >= EQ_BANDS) return;
m_eqGainDb[band] = db;
recomputeBiquad(band);
}
float AudioEngine::eqBand(int band) const {
return (band >= 0 && band < EQ_BANDS) ? m_eqGainDb[band] : 0.f;
}
void AudioEngine::initEq() {
for (int b = 0; b < EQ_BANDS; b++) { m_eqGainDb[b] = 0.f; recomputeBiquad(b); }
}
void AudioEngine::recomputeBiquad(int band) {
float sr = static_cast<float>(m_sampleRate.load());
float f0 = EQ_FREQS[band];
float dBg = m_eqGainDb[band];
float Q = 1.41421356f;
float A = std::pow(10.f, dBg / 40.f);
double w0 = 2.0 * std::numbers::pi * f0 / sr;
double cosW = std::cos(w0), sinW = std::sin(w0);
double alpha= sinW / (2.0 * Q);
double b0 = 1+alpha*A, b1 = -2*cosW, b2 = 1-alpha*A;
double a0 = 1+alpha/A, a1 = -2*cosW, a2 = 1-alpha/A;
m_bqCoeff[band][0] = float(b0/a0); m_bqCoeff[band][1] = float(b1/a0);
m_bqCoeff[band][2] = float(b2/a0); m_bqCoeff[band][3] = float(a1/a0);
m_bqCoeff[band][4] = float(a2/a0);
}
void AudioEngine::applyEq(float *samples, int count, int channels) {
for (int band = 0; band < EQ_BANDS; band++) {
if (std::abs(m_eqGainDb[band]) < 0.01f) continue;
float b0=m_bqCoeff[band][0], b1=m_bqCoeff[band][1], b2=m_bqCoeff[band][2];
float a1=m_bqCoeff[band][3], a2=m_bqCoeff[band][4];
for (int i = 0; i < count; i++) {
int ch = i % channels;
float x = samples[i];
float z1 = m_bqState[band][ch][0], z2 = m_bqState[band][ch][1];
float y = b0*x + z1;
m_bqState[band][ch][0] = b1*x - a1*y + z2;
m_bqState[band][ch][1] = b2*x - a2*y;
samples[i] = y;
}
}
}
void AudioEngine::resetBiquadState() {
for (auto &b : m_bqState) for (auto &c : b) { c[0]=0; c[1]=0; }
}
void AudioEngine::feedFft(const float *samples, int count, int channels) {
for (int i = 0; i < count; i += channels) {
float mono = 0;
for (int c = 0; c < channels; c++) mono += samples[i+c];
mono /= channels;
m_fftBuf[m_fftPos++] = mono;
if (m_fftPos == FFT_SIZE) {
QVector<float> mag = computeFFT(m_fftBuf, FFT_SIZE);
emit fftReady(mag);
std::copy(m_fftBuf + FFT_SIZE/2, m_fftBuf + FFT_SIZE, m_fftBuf);
m_fftPos = FFT_SIZE / 2;
}
}
}
QVector<float> AudioEngine::computeFFT(const float *input, int n) {
std::vector<float> re(input, input + n), im(n, 0.f);
for (int i = 0; i < n; i++)
re[i] *= 0.5f * (1.f - std::cos(2.f * std::numbers::pi_v<float> * i / (n-1)));
for (int j=1, i=0; j<n; j++) {
int bit = n>>1;
for (; (i&bit)!=0; bit>>=1) i^=bit;
i^=bit;
if (j<i) { std::swap(re[j],re[i]); std::swap(im[j],im[i]); }
}
for (int len=2; len<=n; len<<=1) {
double angle = -2.0*std::numbers::pi/len;
float wRe = std::cos(angle), wIm = std::sin(angle);
for (int i=0; i<n; i+=len) {
float cRe=1, cIm=0;
for (int j=0; j<len/2; j++) {
float uRe=re[i+j], uIm=im[i+j];
float vRe=re[i+j+len/2]*cRe - im[i+j+len/2]*cIm;
float vIm=re[i+j+len/2]*cIm + im[i+j+len/2]*cRe;
re[i+j]=uRe+vRe; im[i+j]=uIm+vIm;
re[i+j+len/2]=uRe-vRe; im[i+j+len/2]=uIm-vIm;
float nRe=cRe*wRe-cIm*wIm; cIm=cRe*wIm+cIm*wRe; cRe=nRe;
}
}
}
QVector<float> mag(n/2);
for (int i=0; i<n/2; i++)
mag[i] = std::sqrt(re[i]*re[i]+im[i]*im[i]) / n;
return mag;
}
void AudioEngine::playbackLoop() {
m_state = State::Playing;
AVFormatContext *fmt = nullptr;
AVCodecContext *codec = nullptr;
SwrContext *swr = nullptr;
AVPacket *pkt = av_packet_alloc();
AVFrame *frame = av_frame_alloc();
auto cleanup = [&]{
av_frame_free(&frame);
av_packet_free(&pkt);
swr_free(&swr);
if (codec) avcodec_free_context(&codec);
if (fmt) avformat_close_input(&fmt);
QAudioSink *sinkToDelete = m_sink;
m_sink = nullptr;
m_sinkIO = nullptr;
if (sinkToDelete) {
QMetaObject::invokeMethod(this, [sinkToDelete]{
sinkToDelete->stop();
delete sinkToDelete;
}, Qt::QueuedConnection);
}
m_state = State::Stopped;
};
AVDictionary *opts = nullptr;
av_dict_set(&opts, "probesize", "5000000", 0);
av_dict_set(&opts, "analyzeduration", "5000000", 0);
if (avformat_open_input(&fmt, m_track.filePath.toUtf8().constData(),
nullptr, &opts) < 0) {
av_dict_free(&opts);
qWarning() << "[SubWave] Cannot open:" << m_track.filePath;
cleanup(); return;
}
av_dict_free(&opts);
avformat_find_stream_info(fmt, nullptr);
if (fmt->duration != AV_NOPTS_VALUE && fmt->duration > 0) {
qint64 secs = fmt->duration / AV_TIME_BASE;
if (m_track.durationSecs == 0) m_track.durationSecs = secs;
emit durationKnown(m_track.durationSecs);
}
int audioIdx = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
if (audioIdx < 0) {
qWarning() << "[SubWave] No audio stream:" << m_track.filePath;
cleanup(); return;
}
AVStream *stream = fmt->streams[audioIdx];
const AVCodec *dec = avcodec_find_decoder(stream->codecpar->codec_id);
codec = avcodec_alloc_context3(dec);
avcodec_parameters_to_context(codec, stream->codecpar);
if (avcodec_open2(codec, dec, nullptr) < 0) {
qWarning() << "[SubWave] Cannot open codec";
cleanup(); return;
}
const int srcRate = codec->sample_rate;
const int outRate = srcRate;
const int channels = codec->ch_layout.nb_channels;
m_sampleRate = outRate;
if (m_totalFrames == 0 && m_track.durationSecs > 0)
m_totalFrames = m_track.durationSecs * outRate;
AVChannelLayout chLayout {};
av_channel_layout_default(&chLayout, channels);
swr_alloc_set_opts2(&swr,
&chLayout, AV_SAMPLE_FMT_FLT, outRate,
&codec->ch_layout, codec->sample_fmt, srcRate,
0, nullptr);
swr_init(swr);
QAudioFormat af;
af.setSampleRate(outRate);
af.setChannelCount(channels);
af.setSampleFormat(QAudioFormat::Float);
QSemaphore sinkReady(0);
QMetaObject::invokeMethod(this, [&]{
QAudioDevice dev = QMediaDevices::defaultAudioOutput();
m_sink = new QAudioSink(dev, af);
m_sink->setVolume(m_volume);
m_sinkIO = m_sink->start();
sinkReady.release();
}, Qt::QueuedConnection);
sinkReady.acquire();
if (!m_sinkIO) {
qWarning() << "[SubWave] QAudioSink failed to start";
cleanup(); return;
}
resetBiquadState();
m_posFrames = 0;
std::vector<float> outBuf;
while (!m_stopReq) {
{
QMutexLocker lk(&m_pauseMtx);
while (m_pauseReq && !m_stopReq)
m_pauseCv.wait(&m_pauseMtx, 50);
}
if (m_stopReq) break;
if (m_seekPending) {
m_seekPending = false;
double frac = m_seekFraction.load();
qint64 durUs = (fmt->duration != AV_NOPTS_VALUE && fmt->duration > 0)
? fmt->duration
: (m_track.durationSecs * AV_TIME_BASE);
if (durUs > 0) {
qint64 targetUs = static_cast<qint64>(frac * durUs);
av_seek_frame(fmt, -1, targetUs, AVSEEK_FLAG_BACKWARD);
avcodec_flush_buffers(codec);
m_posFrames = static_cast<qint64>(frac * m_totalFrames.load());
}
resetBiquadState();
continue;
}
int ret = av_read_frame(fmt, pkt);
if (ret < 0) break;
if (pkt->stream_index != audioIdx) {
av_packet_unref(pkt);
continue;
}
avcodec_send_packet(codec, pkt);
av_packet_unref(pkt);
while (!m_stopReq) {
ret = avcodec_receive_frame(codec, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) break;
if (ret < 0) break;
int outSamples = swr_get_out_samples(swr, frame->nb_samples);
outBuf.resize(static_cast<size_t>(outSamples) * channels);
uint8_t *outPtr = reinterpret_cast<uint8_t*>(outBuf.data());
int converted = swr_convert(swr,
&outPtr, outSamples,
const_cast<const uint8_t**>(frame->data), frame->nb_samples);
if (converted <= 0) { av_frame_unref(frame); continue; }
int count = converted * channels;
outBuf.resize(static_cast<size_t>(count));
applyEq(outBuf.data(), count, channels);
feedFft(outBuf.data(), count, channels);
const char *src = reinterpret_cast<const char*>(outBuf.data());
qsizetype bytes = count * sizeof(float);
qsizetype written = 0;
while (written < bytes && !m_stopReq) {
qsizetype n = m_sinkIO->write(src + written, bytes - written);
if (n <= 0) QThread::msleep(1);
else written += n;
}
m_posFrames += converted;
emit positionChanged(m_posFrames.load());
if (m_totalFrames == 0 && stream->duration != AV_NOPTS_VALUE) {
double tb = av_q2d(stream->time_base);
m_totalFrames = static_cast<qint64>(stream->duration * tb * outRate);
}
av_frame_unref(frame);
}
}
bool natural = !m_stopReq;
cleanup();
if (natural) emit trackEnded();
}
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <QObject>
#include <QThread>
#include <QMutex>
#include <QWaitCondition>
#include <QAudioSink>
#include <QSemaphore>
#include <atomic>
#include <vector>
#include "Track.h"
class AudioEngine : public QObject {
Q_OBJECT
public:
enum class State { Stopped, Playing, Paused };
static constexpr int EQ_BANDS = 10;
static constexpr float EQ_FREQS[EQ_BANDS] = {
32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000
};
explicit AudioEngine(QObject *parent = nullptr);
~AudioEngine();
State state() const;
qint64 positionFrames() const;
qint64 totalFrames() const;
int sampleRate() const;
void setVolume(float v);
void play(const Track &track);
void togglePause();
void stop();
void seekFraction(double fraction);
void setEqBand(int band, float db);
float eqBand(int band) const;
signals:
void trackEnded();
void positionChanged(qint64 frames);
void fftReady(QVector<float> magnitudes);
void durationKnown(qint64 secs);
private:
void playbackLoop();
void initEq();
void recomputeBiquad(int band);
void applyEq(float *samples, int count, int channels);
void resetBiquadState();
void feedFft(const float *samples, int count, int channels);
static QVector<float> computeFFT(const float *input, int n);
std::atomic<State> m_state { State::Stopped };
std::atomic<qint64> m_posFrames { 0 };
std::atomic<qint64> m_totalFrames { 0 };
std::atomic<int> m_sampleRate { 44100 };
std::atomic<bool> m_stopReq { false };
std::atomic<bool> m_pauseReq { false };
std::atomic<bool> m_seekPending { false };
std::atomic<double> m_seekFraction{ 0.0 };
float m_volume { 0.8f };
QThread *m_thread { nullptr };
QAudioSink *m_sink { nullptr };
QIODevice *m_sinkIO { nullptr };
QMutex m_pauseMtx;
QWaitCondition m_pauseCv;
Track m_track;
float m_eqGainDb[EQ_BANDS] {};
float m_bqCoeff[EQ_BANDS][5] {};
float m_bqState[EQ_BANDS][2][2] {};
static constexpr int FFT_SIZE = 4096;
float m_fftBuf[FFT_SIZE] {};
int m_fftPos { 0 };
};
+48
View File
@@ -0,0 +1,48 @@
#include "Config.h"
#include <QDir>
#include <QFile>
#include <QStandardPaths>
QString Config::escape(const QString &s) {
return QString(s).replace("\\","\\\\").replace("\"","\\\"");
}
QString Config::unescape(const QString &s) {
return QString(s).replace("\\\\","\\").replace("\\\"","\"").replace("\\/","/");
}
QString Config::extractString(const QString &json, const QString &key) {
QString needle = "\"" + key + "\"";
int ki = json.indexOf(needle);
if (ki < 0) return {};
int colon = json.indexOf(':', ki + needle.size());
if (colon < 0) return {};
int open = json.indexOf('"', colon + 1);
if (open < 0) return {};
int close = open + 1;
while (close < json.size()) {
if (json[close] == '\\') { close += 2; continue; }
if (json[close] == '"') break;
++close;
}
if (close >= json.size()) return {};
return unescape(json.mid(open + 1, close - open - 1));
}
QString Config::loadMusicRoot() {
QFile f(configFile());
if (f.open(QIODevice::ReadOnly)) {
QString json = QString::fromUtf8(f.readAll());
QString path = extractString(json, "musicRoot");
if (!path.isEmpty() && QDir(path).exists()) return path;
}
QString music = QStandardPaths::writableLocation(QStandardPaths::MusicLocation);
return music.isEmpty() ? QDir::homePath() : music;
}
void Config::saveMusicRoot(const QString &root) {
QDir().mkpath(configDir());
QFile f(configFile());
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
f.write(("{\n \"musicRoot\": \"" + escape(root) + "\"\n}\n").toUtf8());
}
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <QString>
#include <QDir>
class Config {
public:
static QString loadMusicRoot();
static void saveMusicRoot(const QString &root);
private:
static QString configDir() { return QDir::homePath() + "/.subwave"; }
static QString configFile() { return configDir() + "/config.json"; }
static QString escape(const QString &s);
static QString unescape(const QString &s);
static QString extractString(const QString &json, const QString &key);
};
+40
View File
@@ -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 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);
}
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <QWidget>
#include <QImage>
#include "Track.h"
class CoverArtWidget : public QWidget {
Q_OBJECT
public:
explicit CoverArtWidget(QWidget *parent = nullptr);
void setTrack(const Track *t);
protected:
void paintEvent(QPaintEvent *) override;
private:
QImage m_cover;
QString m_title { "No track" };
QString m_artist;
};
+289
View File
@@ -0,0 +1,289 @@
#include "EqualizerWidget.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QInputDialog>
#include <QFileDialog>
#include <QFile>
#include <QDir>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QPainter>
#include <QPainterPath>
#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;
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);
QFont sf = m_dbLabels[i]->font(); sf.setPointSizeF(8); m_dbLabels[i]->setFont(sf);
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::currentIndexChanged),
this, &EqualizerWidget::applyPresetAt);
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]{ applyPresetAt(0); });
}
void EqualizerWidget::saveState() {
QDir().mkpath(QFileInfo(stateFile()).absolutePath());
QFile f(stateFile());
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
QString vals;
for (int i=0; i<BANDS; i++) {
if (i) vals += ',';
vals += QString::number(m_sliders[i]->value());
}
f.write(("{\n \"bands\": \"" + vals + "\"\n}\n").toUtf8());
}
void EqualizerWidget::loadPresets() {
m_userPresets.clear();
QFile f(presetFile());
if (!f.open(QIODevice::ReadOnly)) return;
QString json = QString::fromUtf8(f.readAll());
int pos = 0;
while (pos < json.size()) {
int ns = json.indexOf("\"name\"", pos); if (ns<0) break;
int nq1= json.indexOf('"', ns+7); if (nq1<0) break;
int nq2= nq1+1;
while (nq2<json.size() && json[nq2]!='"') {
if (json[nq2]=='\\') nq2++; nq2++;
}
QString name = json.mid(nq1+1, nq2-nq1-1);
int bs = json.indexOf("\"bands\"", nq2); if (bs<0) break;
int bq1= json.indexOf('"', bs+8); if (bq1<0) break;
int bq2= bq1+1;
while (bq2<json.size() && json[bq2]!='"') bq2++;
QString bands = json.mid(bq1+1, bq2-bq1-1);
m_userPresets.append({name, bands});
pos = bq2+1;
}
}
void EqualizerWidget::savePresets() {
QDir().mkpath(QFileInfo(presetFile()).absolutePath());
QFile f(presetFile());
if (!f.open(QIODevice::WriteOnly|QIODevice::Truncate)) return;
QString json = "[\n";
for (int i=0; i<m_userPresets.size(); i++) {
auto escq = [](const QString &s){
return QString(s).replace("\\","\\\\").replace("\"","\\\"");
};
json += " {\"name\":\"" + escq(m_userPresets[i].first)
+ "\",\"bands\":\"" + m_userPresets[i].second + "\"}";
if (i+1<m_userPresets.size()) json += ",";
json += "\n";
}
json += "]\n";
f.write(json.toUtf8());
}
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();
m_presetBox->hidePopup();
}
void EqualizerWidget::loadState() {
QFile f(stateFile());
if (!f.open(QIODevice::ReadOnly)) return;
QString json = QString::fromUtf8(f.readAll());
int bi = json.indexOf("\"bands\""); if (bi<0) return;
int q1 = json.indexOf('"', bi+8); if (q1<0) return;
int q2 = q1+1;
while (q2<json.size() && json[q2]!='"') q2++;
QString vals = json.mid(q1+1, q2-q1-1);
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();
p.fillRect(rect(), palette().base());
float midY = h/2.f;
p.setPen(QColor(180,180,180));
p.drawLine(0,int(midY),w,int(midY));
float logMin = std::log10(20.f), logMax = std::log10(20000.f);
QPainterPath path;
for (int i=0; i<w; i++) {
float f = std::pow(10.f, logMin+(logMax-logMin)*i/w);
double totalDb = 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+lrSq/(Q*Q*(ratio+1.0/ratio)*(ratio+1.0/ratio));
totalDb += db*denom;
}
float py = midY - float(totalDb/12.0*h/2*0.9);
py = std::clamp(py, 0.f, float(h));
if (i==0) path.moveTo(i,py); else path.lineTo(i,py);
}
p.setPen(QPen(palette().windowText().color(), 1.5f));
p.drawPath(path);
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 = (std::log10(f0)-logMin)/(logMax-logMin)*w;
float ny = midY-(db/12.f*h/2*0.9f);
ny = std::clamp(ny,0.f,float(h));
p.drawEllipse(QPointF(nx,ny),3,3);
}
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <QWidget>
#include <QComboBox>
#include <QSlider>
#include <QLabel>
#include <QPushButton>
#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();
};
+361
View File
@@ -0,0 +1,361 @@
#include "MainWindow.h"
#include "AudioEngine.h"
#include "MusicLibrary.h"
#include "PlaylistModel.h"
#include "TrackDelegate.h"
#include "CoverArtWidget.h"
#include "SpectrogramWidget.h"
#include "EqualizerWidget.h"
#include "Config.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 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);
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);
});
// Doubleclick 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) {
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, &MusicLibrary::scanStarted, this, [this]{
m_model->setTracks({});
m_lblStatus->setText("Scanning...");
});
connect(m_library, &MusicLibrary::trackFound, this, [this](const Track &t){
m_model->appendTrack(t);
m_lblStatus->setText(QString("%1 tracks found").arg(m_model->rowCount({})));
}, Qt::QueuedConnection);
connect(m_library, &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)
{
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);
qint64 totSec = totF > 0 ? qint64(totF / sr)
: (m_currentIndex >= 0
? (m_model->trackAt(m_currentIndex)
? m_model->trackAt(m_currentIndex)->durationSecs : 0)
: 0);
// Write back duration if we now know it
if (totF > 0 && m_currentIndex >= 0) {
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");
}
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <QMainWindow>
#include <QSlider>
#include <QLabel>
#include <QPushButton>
#include <QCheckBox>
#include <QListView>
#include <QTimer>
#include <QTabWidget>
#include <QGroupBox>
#include <QHBoxLayout> // needed for buildTransportRow() return type
class AudioEngine;
class MusicLibrary;
class PlaylistModel;
class CoverArtWidget;
class SpectrogramWidget;
class EqualizerWidget;
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();
// Core
AudioEngine *m_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;
};
+77
View File
@@ -0,0 +1,77 @@
#include "MetadataReader.h"
extern "C" {
#include <libavformat/avformat.h>
#include <libavutil/dict.h>
}
void MetadataReader::populate(Track &track) {
AVFormatContext *fmt = nullptr;
if (avformat_open_input(&fmt, track.filePath.toUtf8().constData(),
nullptr, nullptr) < 0) return;
fmt->probesize = 5'000'000;
fmt->max_analyze_duration = 5'000'000;
if (avformat_find_stream_info(fmt, nullptr) < 0) {
avformat_close_input(&fmt);
return;
}
if (fmt->duration != AV_NOPTS_VALUE && fmt->duration > 0)
track.durationSecs = fmt->duration / AV_TIME_BASE;
auto tag = [&](const char *key) -> QString {
AVDictionaryEntry *e = av_dict_get(fmt->metadata, key, nullptr,
AV_DICT_IGNORE_SUFFIX);
if (!e) return {};
return QString::fromUtf8(e->value).trimmed();
};
auto tagOr = [&](std::initializer_list<const char*> keys) -> QString {
for (auto k : keys) {
QString v = tag(k);
if (!v.isEmpty()) return v;
}
return {};
};
QString t = tagOr({"title", "TITLE"});
if (!t.isEmpty()) track.title = t;
QString a = tagOr({"artist", "ARTIST", "album_artist", "ALBUM_ARTIST"});
if (!a.isEmpty()) track.artist = a;
QString al = tagOr({"album", "ALBUM"});
if (!al.isEmpty()) track.album = al;
QString yr = tagOr({"date", "DATE", "year", "YEAR", "TDRC", "TYER"});
if (yr.length() >= 4) {
bool ok = false;
int y = yr.left(4).toInt(&ok);
if (ok) track.year = y;
}
QString trck = tagOr({"track", "TRACK", "TRCK"});
if (!trck.isEmpty()) {
int slash = trck.indexOf('/');
bool ok = false;
int tn = (slash >= 0 ? trck.left(slash) : trck).toInt(&ok);
if (ok) track.trackNumber = tn;
}
for (unsigned i = 0; i < fmt->nb_streams; ++i) {
AVStream *s = fmt->streams[i];
if (s->disposition & AV_DISPOSITION_ATTACHED_PIC) {
AVPacket &pkt = s->attached_pic;
QImage img;
if (img.loadFromData(
reinterpret_cast<const uchar*>(pkt.data), pkt.size)) {
track.coverArt = img;
}
break;
}
}
avformat_close_input(&fmt);
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include "Track.h"
class MetadataReader {
public:
static void populate(Track &track);
};
+59
View File
@@ -0,0 +1,59 @@
#include "MusicLibrary.h"
#include "MetadataReader.h"
#include <QDirIterator>
#include <QFileInfo>
MusicLibrary::MusicLibrary(QObject *parent) : QObject(parent) {}
const QList<Track> &MusicLibrary::tracks() const { return m_tracks; }
const QString &MusicLibrary::rootPath() const { return m_root; }
void MusicLibrary::setRootPath(const QString &path) { m_root = path; }
void MusicLibrary::scan() {
if (m_scanThread && m_scanThread->isRunning()) {
m_scanThread->requestInterruption();
m_scanThread->wait();
}
m_tracks.clear();
emit scanStarted();
m_scanThread = QThread::create([this]{
QDirIterator it(m_root,
QStringList() << "*.mp3" << "*.flac" << "*.wav"
<< "*.ogg" << "*.aac" << "*.m4a"
<< "*.opus"<< "*.wma",
QDir::Files, QDirIterator::Subdirectories);
while (it.hasNext() && !QThread::currentThread()->isInterruptionRequested()) {
QString path = it.next();
Track t;
t.filePath = path;
QString ext = QFileInfo(path).suffix().toLower();
t.format = ext.toUpper();
t.title = QFileInfo(path).completeBaseName();
t.artist = "Unknown Artist";
t.album = "Unknown Album";
MetadataReader::populate(t);
QMutexLocker lk(&m_mtx);
m_tracks.append(t);
emit trackFound(t);
}
sortByArtistTitle();
emit scanComplete(m_tracks.size());
});
m_scanThread->setObjectName("subwave-scan");
m_scanThread->start(QThread::LowPriority);
}
void MusicLibrary::sortByArtistTitle() {
QMutexLocker lk(&m_mtx);
std::sort(m_tracks.begin(), m_tracks.end(), [](const Track &a, const Track &b){
int c = a.artist.compare(b.artist, Qt::CaseInsensitive);
return c != 0 ? c < 0 : a.title.compare(b.title, Qt::CaseInsensitive) < 0;
});
}
void MusicLibrary::shutdown() {
if (m_scanThread) { m_scanThread->requestInterruption(); m_scanThread->wait(); }
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <QObject>
#include <QList>
#include <QThread>
#include <QMutex>
#include "Track.h"
class MusicLibrary : public QObject {
Q_OBJECT
public:
static const inline QStringList SUPPORTED_EXTS =
{ "mp3", "flac", "wav", "ogg", "aac", "m4a", "opus", "wma" };
explicit MusicLibrary(QObject *parent = nullptr);
const QList<Track> &tracks() const;
const QString &rootPath() const;
void setRootPath(const QString &path);
void scan();
void sortByArtistTitle();
void shutdown();
signals:
void scanStarted();
void trackFound(const Track &track);
void scanComplete(int count);
private:
QString m_root;
QList<Track> m_tracks;
QThread *m_scanThread { nullptr };
QMutex m_mtx;
};
+58
View File
@@ -0,0 +1,58 @@
#include "PlaylistModel.h"
PlaylistModel::PlaylistModel(QObject *parent)
: QAbstractListModel(parent) {}
void PlaylistModel::setTracks(const QList<Track> &tracks) {
beginResetModel();
m_tracks = tracks;
endResetModel();
}
void PlaylistModel::appendTrack(const Track &t) {
beginInsertRows({}, m_tracks.size(), m_tracks.size());
m_tracks.append(t);
endInsertRows();
}
void PlaylistModel::setCurrentIndex(int idx) {
int old = m_current;
m_current = idx;
if (old >= 0) emit dataChanged(index(old), index(old), {Qt::UserRole + 1});
if (idx >= 0) emit dataChanged(index(idx), index(idx), {Qt::UserRole + 1});
}
void PlaylistModel::refreshRow(int idx) {
if (idx >= 0 && idx < m_tracks.size())
emit dataChanged(index(idx), index(idx));
}
Track *PlaylistModel::trackAt(int idx) {
if (idx < 0 || idx >= m_tracks.size()) return nullptr;
return &m_tracks[idx];
}
int PlaylistModel::rowCount(const QModelIndex &) const {
return m_tracks.size();
}
QVariant PlaylistModel::data(const QModelIndex &idx, int role) const {
if (!idx.isValid() || idx.row() >= m_tracks.size())
return {};
const Track &t = m_tracks[idx.row()];
switch (role) {
case Qt::DisplayRole:
return t.displayTitle();
case Qt::UserRole: // subline: artist, album, format, duration
return t.artist + " \u2013 " + t.album
+ " [" + t.format + "]"
+ " " + t.durationString();
case Qt::UserRole + 1: // isplaying flag
return idx.row() == m_current;
}
return {};
}
int PlaylistModel::currentIndex() const {
return m_current;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <QAbstractListModel>
#include <QList>
#include "Track.h"
class PlaylistModel : public QAbstractListModel {
Q_OBJECT
public:
explicit PlaylistModel(QObject *parent=nullptr);
void setTracks(const QList<Track> &tracks);
void appendTrack(const Track &t);
void setCurrentIndex(int idx);
void refreshRow(int idx);
Track *trackAt(int idx);
int rowCount(const QModelIndex &) const override;
QVariant data(const QModelIndex &idx, int role) const override;
int currentIndex() const;
private:
QList<Track> m_tracks;
int m_current { -1 };
};
+166
View File
@@ -0,0 +1,166 @@
#include "SpectrogramWidget.h"
#include <QPainter>
#include <algorithm>
#include <cmath>
SpectrogramWidget::SpectrogramWidget(QWidget *parent) : QWidget(parent) {
setMinimumSize(400, 200);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
buildHeatmap();
m_renderTimer.setInterval(16);
connect(&m_renderTimer, &QTimer::timeout, this, [this]{
bool updated = false;
QVector<float> mag;
{
QMutexLocker lk(&m_queueMtx);
while (!m_fftQueue.isEmpty()) {
mag = m_fftQueue.dequeue();
updated = true;
}
}
if (updated) {
m_currentMag = mag;
scrollWaterfall();
update();
}
});
m_renderTimer.start();
}
void SpectrogramWidget::receiveFft(const QVector<float> &mag) {
QMutexLocker lk(&m_queueMtx);
if (m_fftQueue.size() < 16) m_fftQueue.enqueue(mag);
}
void SpectrogramWidget::reset() {
m_currentMag.fill(0.f, 2048);
m_barSmoothed.fill(0.f, BAR_COUNT);
m_barPeak.fill(0.f, BAR_COUNT);
m_peakTimer.fill(0, BAR_COUNT);
m_waterfall = QImage{};
update();
}
void SpectrogramWidget::paintEvent(QPaintEvent *) {
QPainter p(this);
int w = width(), h = height();
if (w < 8 || h < 8) return;
int wfH = h / 2;
int barH = h - wfH;
if (!m_waterfall.isNull())
p.drawImage(0, 0, m_waterfall);
p.setPen(QColor(100,100,100));
p.drawLine(0, wfH-1, w, wfH-1);
drawSpectrum(p, 0, wfH, w, barH);
}
void SpectrogramWidget::resizeEvent(QResizeEvent *) {
m_waterfall = QImage{};
}
void SpectrogramWidget::buildHeatmap() {
struct Stop { int r,g,b; };
Stop stops[] = {{0,0,0},{0,0,180},{0,255,255},{255,255,0},{255,0,0}};
int nSegs = 4;
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;
auto a = stops[seg], b = stops[seg+1];
auto lerp = [](int x, int y, float f){ return std::clamp(int(x+(y-x)*f),0,255); };
m_heatmap[i] = qRgb(lerp(a.r,b.r,f), lerp(a.g,b.g,f), lerp(a.b,b.b,f));
}
}
void SpectrogramWidget::ensureWaterfall(int w, int wfH) {
if (m_waterfall.width() == w && m_waterfall.height() == wfH) return;
QImage fresh(w, std::max(1,wfH), QImage::Format_RGB32);
fresh.fill(Qt::black);
if (!m_waterfall.isNull())
QPainter(&fresh).drawImage(0,0,m_waterfall);
m_waterfall = std::move(fresh);
}
void SpectrogramWidget::scrollWaterfall() {
int w = width(), h = height();
int wfH = h / 2;
if (w < 2 || wfH < 2) return;
ensureWaterfall(w, wfH);
memmove(m_waterfall.bits(),
m_waterfall.bits() + m_waterfall.bytesPerLine(),
static_cast<size_t>((wfH-1) * m_waterfall.bytesPerLine()));
computeBars();
int fftLen = m_currentMag.size();
double logMin = std::log10(20.0), logMax = std::log10(22050.0);
QRgb *row = reinterpret_cast<QRgb*>(
m_waterfall.bits() + (wfH-1)*m_waterfall.bytesPerLine());
for (int px = 0; px < w; px++) {
double freq = std::pow(10.0, logMin + (logMax-logMin)*px/w);
int bin = std::min(fftLen-1, int(freq/22050.0*fftLen));
int binL = std::max(0, bin-1), binR = std::min(fftLen-1, bin+1);
float mag = 0.f;
for (int k=binL; k<=binR; k++) mag += m_currentMag[k];
mag /= (binR-binL+1);
float db = mag>0 ? 20.f*std::log10(mag) : DB_MIN;
float val = std::clamp((db-DB_MIN)/(DB_MAX-DB_MIN), 0.f, 1.f);
row[px] = m_heatmap[int(val*255)];
}
}
void SpectrogramWidget::computeBars() {
int fftLen = m_currentMag.size();
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::max(0, std::min(fftLen-1, int(f1/22050.0*fftLen)));
int bin2 = std::max(bin1, std::min(fftLen-1, int(f2/22050.0*fftLen)));
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);
}
}
}
void SpectrogramWidget::drawSpectrum(QPainter &p, int x, int y, int w, int h) {
int barW = std::max(1, w/BAR_COUNT);
int gap = std::max(1, barW/5);
for (int i=0; i<BAR_COUNT; i++) {
float val = m_barSmoothed[i];
int bx = x + i*barW;
int bh = int(val*(h-12));
int ci = int(val*255);
p.fillRect(bx+gap/2, y+h-12-bh, barW-gap, bh, QColor::fromRgb(m_heatmap[ci]));
if (m_barPeak[i] > 0.01f) {
int ph = int(m_barPeak[i]*(h-12));
p.fillRect(bx+gap/2, y+h-12-ph-1, barW-gap, 2, Qt::white);
}
}
p.setPen(palette().windowText().color());
QFont f = p.font(); f.setPointSizeF(7); p.setFont(f);
const char *lbls[] = {"32","125","500","2k","8k","16k"};
float freqs[]= {32,125,500,2000,8000,16000};
double logMin=std::log10(20.0), logMax=std::log10(22050.0);
for (int i=0; i<6; i++) {
double nx = (std::log10(freqs[i])-logMin)/(logMax-logMin)*w;
p.drawText(x+int(nx), y+h-1, lbls[i]);
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <QWidget>
#include <QTimer>
#include <QMutex>
#include <QQueue>
#include <QImage>
#include <array>
class SpectrogramWidget : public QWidget {
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);
public slots:
void receiveFft(const QVector<float> &mag);
void reset();
protected:
void paintEvent(QPaintEvent *) override;
void resizeEvent(QResizeEvent *) override;
private:
void buildHeatmap();
void ensureWaterfall(int w, int wfH);
void scrollWaterfall();
void computeBars();
void drawSpectrum(QPainter &p, int x, int y, int w, int h);
QTimer m_renderTimer;
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);
QImage m_waterfall;
std::array<QRgb,256> m_heatmap {};
};
View File
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <QString>
#include <QImage>
#include <QFileInfo>
struct Track {
QString filePath;
QString title;
QString artist;
QString album;
int year = 0;
int trackNumber = 0;
qint64 durationSecs = 0;
QImage coverArt;
QString format;
QString displayTitle() const {
return (!title.isEmpty()) ? title : QFileInfo(filePath).fileName();
}
QString durationString() const {
if (durationSecs <= 0) return "--:--";
return QString("%1:%2")
.arg(durationSecs / 60)
.arg(durationSecs % 60, 2, 10, QChar('0'));
}
QString toString() const {
return artist + " \u2014 " + title;
}
};
+56
View File
@@ -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();
}
+10
View File
@@ -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;
};
+13
View File
@@ -0,0 +1,13 @@
#include <QApplication>
#include "MainWindow.h"
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
app.setApplicationName("SubWave");
app.setOrganizationName("SubWave");
MainWindow w;
w.show();
return app.exec();
}