initial commit
This commit is contained in:
+58
@@ -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
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
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");
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include "Track.h"
|
||||
|
||||
class MetadataReader {
|
||||
public:
|
||||
static void populate(Track &track);
|
||||
};
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: // sub‑line: artist, album, format, duration
|
||||
return t.artist + " \u2013 " + t.album
|
||||
+ " [" + t.format + "]"
|
||||
+ " " + t.durationString();
|
||||
case Qt::UserRole + 1: // is‑playing flag
|
||||
return idx.row() == m_current;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
int PlaylistModel::currentIndex() const {
|
||||
return m_current;
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
};
|
||||
+31
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user