commit 0d125993a571ed0dd2180992765dd375f537b1f8 Author: rattatwinko Date: Tue Jun 2 19:07:34 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42d23d9 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..5266fb0 --- /dev/null +++ b/CMakeLists.txt @@ -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 + "$" "$" + ) +endif() \ No newline at end of file diff --git a/src/AudioEngine.cpp b/src/AudioEngine.cpp new file mode 100644 index 0000000..bb78713 --- /dev/null +++ b/src/AudioEngine.cpp @@ -0,0 +1,355 @@ +#include "AudioEngine.h" +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +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(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 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 AudioEngine::computeFFT(const float *input, int n) { + std::vector 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 * i / (n-1))); + for (int j=1, i=0; j>1; + for (; (i&bit)!=0; bit>>=1) i^=bit; + i^=bit; + if (j mag(n/2); + for (int i=0; istop(); + 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 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(frac * durUs); + av_seek_frame(fmt, -1, targetUs, AVSEEK_FLAG_BACKWARD); + avcodec_flush_buffers(codec); + m_posFrames = static_cast(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(outSamples) * channels); + + uint8_t *outPtr = reinterpret_cast(outBuf.data()); + int converted = swr_convert(swr, + &outPtr, outSamples, + const_cast(frame->data), frame->nb_samples); + + if (converted <= 0) { av_frame_unref(frame); continue; } + + int count = converted * channels; + outBuf.resize(static_cast(count)); + + applyEq(outBuf.data(), count, channels); + feedFft(outBuf.data(), count, channels); + + const char *src = reinterpret_cast(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(stream->duration * tb * outRate); + } + + av_frame_unref(frame); + } + } + + bool natural = !m_stopReq; + cleanup(); + if (natural) emit trackEnded(); +} \ No newline at end of file diff --git a/src/AudioEngine.h b/src/AudioEngine.h new file mode 100644 index 0000000..f6525ac --- /dev/null +++ b/src/AudioEngine.h @@ -0,0 +1,78 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#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 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 computeFFT(const float *input, int n); + + std::atomic m_state { State::Stopped }; + std::atomic m_posFrames { 0 }; + std::atomic m_totalFrames { 0 }; + std::atomic m_sampleRate { 44100 }; + std::atomic m_stopReq { false }; + std::atomic m_pauseReq { false }; + std::atomic m_seekPending { false }; + std::atomic 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 }; +}; \ No newline at end of file diff --git a/src/Config.cpp b/src/Config.cpp new file mode 100644 index 0000000..334b0dd --- /dev/null +++ b/src/Config.cpp @@ -0,0 +1,48 @@ +#include "Config.h" +#include +#include +#include + +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()); + } +} \ No newline at end of file diff --git a/src/Config.h b/src/Config.h new file mode 100644 index 0000000..b81819d --- /dev/null +++ b/src/Config.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +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); +}; \ No newline at end of file diff --git a/src/CoverArtWidget.cpp b/src/CoverArtWidget.cpp new file mode 100644 index 0000000..6afa850 --- /dev/null +++ b/src/CoverArtWidget.cpp @@ -0,0 +1,40 @@ +#include "CoverArtWidget.h" +#include +#include + +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); + } +} \ No newline at end of file diff --git a/src/CoverArtWidget.h b/src/CoverArtWidget.h new file mode 100644 index 0000000..580275d --- /dev/null +++ b/src/CoverArtWidget.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#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; +}; \ No newline at end of file diff --git a/src/EqualizerWidget.cpp b/src/EqualizerWidget.cpp new file mode 100644 index 0000000..2858666 --- /dev/null +++ b/src/EqualizerWidget.cpp @@ -0,0 +1,289 @@ +#include "EqualizerWidget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const inline QVector> 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::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; ivalue()/10.f,'f',1); + } + m_userPresets.removeIf([&](const QPair &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; ivalue()); + } + 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 (nq2clear(); + 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; isetValue(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 (q2setValue(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; ivalue()/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; bvalue()/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); + } +} \ No newline at end of file diff --git a/src/EqualizerWidget.h b/src/EqualizerWidget.h new file mode 100644 index 0000000..07d056a --- /dev/null +++ b/src/EqualizerWidget.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include +#include +#include +#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> 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(); +}; \ No newline at end of file diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..86f48a0 --- /dev/null +++ b/src/MainWindow.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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"); +} \ No newline at end of file diff --git a/src/MainWindow.h b/src/MainWindow.h new file mode 100644 index 0000000..e9979f7 --- /dev/null +++ b/src/MainWindow.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // 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; +}; \ No newline at end of file diff --git a/src/MetadataReader.cpp b/src/MetadataReader.cpp new file mode 100644 index 0000000..859ba2c --- /dev/null +++ b/src/MetadataReader.cpp @@ -0,0 +1,77 @@ +#include "MetadataReader.h" + +extern "C" { +#include +#include +} + +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 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(pkt.data), pkt.size)) { + track.coverArt = img; + } + break; + } + } + + avformat_close_input(&fmt); +} \ No newline at end of file diff --git a/src/MetadataReader.h b/src/MetadataReader.h new file mode 100644 index 0000000..96c7cd8 --- /dev/null +++ b/src/MetadataReader.h @@ -0,0 +1,7 @@ +#pragma once +#include "Track.h" + +class MetadataReader { +public: + static void populate(Track &track); +}; \ No newline at end of file diff --git a/src/MusicLibrary.cpp b/src/MusicLibrary.cpp new file mode 100644 index 0000000..18280ac --- /dev/null +++ b/src/MusicLibrary.cpp @@ -0,0 +1,59 @@ +#include "MusicLibrary.h" +#include "MetadataReader.h" +#include +#include + +MusicLibrary::MusicLibrary(QObject *parent) : QObject(parent) {} + +const QList &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(); } +} \ No newline at end of file diff --git a/src/MusicLibrary.h b/src/MusicLibrary.h new file mode 100644 index 0000000..74b5212 --- /dev/null +++ b/src/MusicLibrary.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include +#include +#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 &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 m_tracks; + QThread *m_scanThread { nullptr }; + QMutex m_mtx; +}; \ No newline at end of file diff --git a/src/PlaylistModel.cpp b/src/PlaylistModel.cpp new file mode 100644 index 0000000..cccbff5 --- /dev/null +++ b/src/PlaylistModel.cpp @@ -0,0 +1,58 @@ +#include "PlaylistModel.h" + +PlaylistModel::PlaylistModel(QObject *parent) + : QAbstractListModel(parent) {} + +void PlaylistModel::setTracks(const QList &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; +} \ No newline at end of file diff --git a/src/PlaylistModel.h b/src/PlaylistModel.h new file mode 100644 index 0000000..34f1942 --- /dev/null +++ b/src/PlaylistModel.h @@ -0,0 +1,22 @@ +#pragma once +#include +#include +#include "Track.h" + +class PlaylistModel : public QAbstractListModel { + Q_OBJECT +public: + explicit PlaylistModel(QObject *parent=nullptr); + void setTracks(const QList &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 m_tracks; + int m_current { -1 }; +}; \ No newline at end of file diff --git a/src/SpectrogramWidget.cpp b/src/SpectrogramWidget.cpp new file mode 100644 index 0000000..49a786c --- /dev/null +++ b/src/SpectrogramWidget.cpp @@ -0,0 +1,166 @@ +#include "SpectrogramWidget.h" +#include +#include +#include + +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 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 &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((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( + 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 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; b0 ? 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 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 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]); + } +} \ No newline at end of file diff --git a/src/SpectrogramWidget.h b/src/SpectrogramWidget.h new file mode 100644 index 0000000..0f68079 --- /dev/null +++ b/src/SpectrogramWidget.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +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 &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> m_fftQueue; + QVector m_currentMag = QVector(2048, 0.f); + QVector m_barSmoothed = QVector(BAR_COUNT, 0.f); + QVector m_barPeak = QVector(BAR_COUNT, 0.f); + QVector m_peakTimer = QVector(BAR_COUNT, 0); + QImage m_waterfall; + std::array m_heatmap {}; +}; \ No newline at end of file diff --git a/src/Track.cpp b/src/Track.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/Track.h b/src/Track.h new file mode 100644 index 0000000..606a09d --- /dev/null +++ b/src/Track.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include + +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; + } +}; \ No newline at end of file diff --git a/src/TrackDelegate.cpp b/src/TrackDelegate.cpp new file mode 100644 index 0000000..8113e0b --- /dev/null +++ b/src/TrackDelegate.cpp @@ -0,0 +1,56 @@ +#include "TrackDelegate.h" +#include + +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(); +} \ No newline at end of file diff --git a/src/TrackDelegate.h b/src/TrackDelegate.h new file mode 100644 index 0000000..ae1488f --- /dev/null +++ b/src/TrackDelegate.h @@ -0,0 +1,10 @@ +#pragma once +#include + +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; +}; \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..256af53 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,13 @@ +#include +#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(); +} \ No newline at end of file