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