From 1a00e9866139094703cb706383b23aa6d0fc6a80 Mon Sep 17 00:00:00 2001 From: rattatwinko Date: Wed, 1 Jul 2026 09:56:37 +0200 Subject: [PATCH] rather large commit, it now handles memory and threading properly. --- more thorough commit message which was generated by the wonderful copilot AI tool: Refactor and enhance audio encoding and decoding pipelines - Introduced CodecUtils.h for utility functions related to audio codec selection, including sample format and bitrate clamping. - Added DecoderPipeline.h to manage audio decoding, including opening files and retrieving audio stream information. - Implemented EncoderPipeline.h to handle audio encoding, including encoder setup, resampling, and writing output files. - Created OutputPathBuilder.h to generate output file paths based on reencode job specifications. - Refactored SpectrogramWidget to improve OpenGL handling and ensure safe context usage. - Updated ReencodeDialog.h to enhance code readability and maintainability. - Improved error handling and logging throughout the audio processing components. --- CMakeLists.txt | 16 +- build.sh | 4 + src/engine/AlbumReencoder.cpp | 477 ------------------------- src/engine/AlbumReencoder.h | 44 --- src/engine/encoder/AlbumReencoder.cpp | 58 ++- src/engine/encoder/AlbumReencoder.h | 6 +- src/engine/encoder/CodecUtils.h | 76 ++++ src/engine/encoder/DecoderPipeline.h | 84 +++++ src/engine/encoder/EncoderPipeline.h | 199 +++++++++++ src/engine/encoder/OutputPathBuilder.h | 24 ++ src/engine/encoder/SampleRateUtils.h | 12 +- src/engine/encoder/StageBuffer.cpp | 104 ++++-- src/engine/encoder/TrackEncoder.cpp | 313 ++++------------ src/gui/MainWindow.cpp | 110 +++--- src/gui/MainWindow.h | 19 +- src/gui/ReencodeDialog.cpp | 113 +++--- src/gui/ReencodeDialog.h | 39 +- src/gui/SpectrogramWidget.cpp | 291 ++++++++++----- src/gui/SpectrogramWidget.h | 79 ++-- 19 files changed, 984 insertions(+), 1084 deletions(-) delete mode 100644 src/engine/AlbumReencoder.cpp delete mode 100644 src/engine/AlbumReencoder.h create mode 100644 src/engine/encoder/CodecUtils.h create mode 100644 src/engine/encoder/DecoderPipeline.h create mode 100644 src/engine/encoder/EncoderPipeline.h create mode 100644 src/engine/encoder/OutputPathBuilder.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c353bdc..1973a6c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,7 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) + # dependencies find_package(Qt6 REQUIRED COMPONENTS Core Widgets Multimedia OpenGL OpenGLWidgets @@ -16,6 +17,7 @@ pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libswresample ) pkg_check_modules(FFTW REQUIRED IMPORTED_TARGET fftw3) + # sources set(SOURCES src/main.cpp @@ -55,6 +57,7 @@ set(SOURCES # reencode src/gui/ReencodeDialog.cpp ) + set(HEADERS src/config/Config.h src/gui/CoverArtWidget.h @@ -84,6 +87,10 @@ set(HEADERS src/engine/encoder/StageBuffer.h src/engine/encoder/FfmpegGuards.h src/engine/encoder/SampleRateUtils.h + src/engine/encoder/CodecUtils.h # new: pickSampleFormat/pickChannelCount/clampBitrate/ChannelLayoutGuard + src/engine/encoder/OutputPathBuilder.h # new: buildOutputPath + src/engine/encoder/DecoderPipeline.h # new: input open + decoder context + src/engine/encoder/EncoderPipeline.h # new: encoder + muxer + SWR + encode frame # ffmpeg wrapper src/engine/ffmpeg/FormatContext.h src/engine/ffmpeg/CodecContext.h @@ -97,33 +104,39 @@ set(HEADERS # reencode src/gui/ReencodeDialog.h ) + # qrc resources set(RESOURCES src/shaders.qrc src/application.qrc ) + # target add_executable(SubWave ${SOURCES} ${HEADERS} ${RESOURCES}) + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) message(STATUS "Setting build type to 'RelWithDebInfo' as none was specified.") set(CMAKE_BUILD_TYPE "RelWithDebInfo" CACHE STRING "Choose the type of build." FORCE) endif() + if(MSVC) target_compile_options(SubWave PRIVATE /O2 /fp:fast) else() target_compile_options(SubWave PRIVATE -O3 -ffast-math -march=native) endif() + # headers target_include_directories(SubWave PRIVATE src src/config src/controllers src/engine - src/engine/encoder + src/engine/encoder # covers CodecUtils.h, OutputPathBuilder.h, DecoderPipeline.h, EncoderPipeline.h src/engine/ffmpeg src/playlists src/gui ) + # link to qt and ffmpeg and fftw target_link_libraries(SubWave PRIVATE Qt6::Core @@ -134,6 +147,7 @@ target_link_libraries(SubWave PRIVATE PkgConfig::FFMPEG PkgConfig::FFTW ) + # For windows stupid ahh if(WIN32) set_target_properties(SubWave PROPERTIES WIN32_EXECUTABLE TRUE) diff --git a/build.sh b/build.sh index 47d6623..8db33d8 100755 --- a/build.sh +++ b/build.sh @@ -46,6 +46,7 @@ clean_appimage() { echo "No AppImage found, aborting" } + case "${1:-build}" in clean|c) clean @@ -65,6 +66,9 @@ case "${1:-build}" in cai|clean_appimage|cleanimg) clean_appimage ;; + cbr|clean_build_run) + clean_build && run_executable + ;; *) echo "Usage: $0 {build|clean|cleanbuild|cb}" exit 1 diff --git a/src/engine/AlbumReencoder.cpp b/src/engine/AlbumReencoder.cpp deleted file mode 100644 index 37b48aa..0000000 --- a/src/engine/AlbumReencoder.cpp +++ /dev/null @@ -1,477 +0,0 @@ -#include "AlbumReencoder.h" -#include -#include -#include - -extern "C" { -#include -#include -#include -#include -#include -#include -} - -namespace engine { - -namespace { - -struct FmtCtxIn { - AVFormatContext *p = nullptr; - ~FmtCtxIn() { if (p) avformat_close_input(&p); } -}; - -struct FmtCtxOut { - AVFormatContext *p = nullptr; - ~FmtCtxOut() { - if (p) { - if (!(p->oformat->flags & AVFMT_NOFILE)) - avio_closep(&p->pb); - avformat_free_context(p); - } - } -}; - -struct CodecCtxGuard { - AVCodecContext *p = nullptr; - ~CodecCtxGuard() { if (p) avcodec_free_context(&p); } -}; - -struct SwrGuard { - SwrContext *p = nullptr; - ~SwrGuard() { if (p) swr_free(&p); } -}; - -struct FrameGuard { - AVFrame *p = av_frame_alloc(); - ~FrameGuard() { av_frame_free(&p); } -}; - -struct PacketGuard { - AVPacket *p = av_packet_alloc(); - ~PacketGuard() { av_packet_free(&p); } -}; - -QString buildOutputPath(const ReencodeJob &job) -{ - QString outDir = job.outputDir; - if (outDir.isEmpty()) - outDir = QFileInfo(job.track.filePath).absolutePath(); - - QDir().mkpath(outDir); - - QString base = QFileInfo(job.track.filePath).completeBaseName(); - return outDir + QDir::separator() + base + "." + job.preset.extension; -} - -} // anonymous namespace - -AlbumReencoder::AlbumReencoder(QObject *parent) : QObject(parent) {} - -AlbumReencoder::~AlbumReencoder() -{ - requestStop(); - if (m_thread) { - m_thread->wait(8000); - delete m_thread; - } -} - -void AlbumReencoder::setJobs(const QList &jobs) -{ - QMutexLocker lk(&m_mtx); - m_jobs = jobs; -} - -QList AlbumReencoder::jobs() const -{ - QMutexLocker lk(&m_mtx); - return m_jobs; -} - -bool AlbumReencoder::isRunning() const -{ - return m_thread && m_thread->isRunning(); -} - -void AlbumReencoder::requestStop() -{ - m_stopReq = true; -} - -void AlbumReencoder::start() -{ - if (isRunning()) return; - m_stopReq = false; - - m_thread = QThread::create([this]{ workerRun(); }); - m_thread->setObjectName("subwave-reencode"); - connect(m_thread, &QThread::finished, m_thread, &QThread::deleteLater); - m_thread = nullptr; - m_thread = QThread::create([this]{ workerRun(); }); - m_thread->setObjectName("subwave-reencode"); - m_thread->start(QThread::LowPriority); -} - -void AlbumReencoder::workerRun() -{ - int succeeded = 0, failed = 0; - - QMutexLocker lk(&m_mtx); - int total = m_jobs.size(); - lk.unlock(); - - for (int i = 0; i < total; ++i) { - if (m_stopReq) break; - - lk.relock(); - ReencodeJob &job = m_jobs[i]; - job.status = ReencodeJob::Status::Running; - lk.unlock(); - - emit trackStarted(i, job.track.displayTitle()); - - bool ok = encodeTrack(job); - - lk.relock(); - job.status = ok ? ReencodeJob::Status::Done : ReencodeJob::Status::Failed; - lk.unlock(); - - if (ok) ++succeeded; else ++failed; - emit trackFinished(i, ok, job.outputPath, job.errorString); - } - - emit allFinished(succeeded, failed); -} - -bool AlbumReencoder::encodeTrack(ReencodeJob &job) -{ - job.outputPath = buildOutputPath(job); - job.errorString = {}; - - FmtCtxIn in; - { - AVDictionary *opts = nullptr; - av_dict_set(&opts, "probesize", "5000000", 0); - av_dict_set(&opts, "analyzeduration", "5000000", 0); - int err = avformat_open_input(&in.p, - job.track.filePath.toUtf8().constData(), - nullptr, &opts); - av_dict_free(&opts); - if (err < 0) { - job.errorString = "Cannot open input file"; - return false; - } - } - if (avformat_find_stream_info(in.p, nullptr) < 0) { - job.errorString = "Cannot read stream info"; - return false; - } - - int audioStreamIdx = av_find_best_stream(in.p, AVMEDIA_TYPE_AUDIO, - -1, -1, nullptr, 0); - if (audioStreamIdx < 0) { - job.errorString = "No audio stream found"; - return false; - } - - AVStream *inStream = in.p->streams[audioStreamIdx]; - - const AVCodec *decoder = avcodec_find_decoder(inStream->codecpar->codec_id); - if (!decoder) { - job.errorString = "No decoder for input codec"; - return false; - } - CodecCtxGuard decCtx; - decCtx.p = avcodec_alloc_context3(decoder); - if (!decCtx.p) { job.errorString = "OOM decoder ctx"; return false; } - - avcodec_parameters_to_context(decCtx.p, inStream->codecpar); - if (avcodec_open2(decCtx.p, decoder, nullptr) < 0) { - job.errorString = "Cannot open decoder"; - return false; - } - - int inChannels = decCtx.p->ch_layout.nb_channels; - int inSampleRate = decCtx.p->sample_rate; - - const AVCodec *encoder = avcodec_find_encoder_by_name( - job.preset.codecName.toUtf8().constData()); - if (!encoder) { - job.errorString = QString("Encoder not found: %1").arg(job.preset.codecName); - return false; - } - - CodecCtxGuard encCtx; - encCtx.p = avcodec_alloc_context3(encoder); - if (!encCtx.p) { job.errorString = "OOM encoder ctx"; return false; } - - AVSampleFormat encFmt = AV_SAMPLE_FMT_FLTP; - if (encoder->sample_fmts) { - encFmt = encoder->sample_fmts[0]; - for (int i = 0; encoder->sample_fmts[i] != AV_SAMPLE_FMT_NONE; ++i) { - if (encoder->sample_fmts[i] == AV_SAMPLE_FMT_FLTP) { - encFmt = AV_SAMPLE_FMT_FLTP; - break; - } - } - - // pcm_s16le wants S16 - if (job.preset.codecName == "pcm_s16le") { - for (int i = 0; encoder->sample_fmts[i] != AV_SAMPLE_FMT_NONE; ++i) { - if (encoder->sample_fmts[i] == AV_SAMPLE_FMT_S16) { - encFmt = AV_SAMPLE_FMT_S16; - break; - } - } - } - } - - AVChannelLayout outLayout{}; - av_channel_layout_default(&outLayout, inChannels); - av_channel_layout_copy(&encCtx.p->ch_layout, &outLayout); - - encCtx.p->sample_rate = inSampleRate; - encCtx.p->sample_fmt = encFmt; - encCtx.p->time_base = { 1, inSampleRate }; - - if (job.preset.bitrate > 0) - encCtx.p->bit_rate = static_cast(job.preset.bitrate) * 1000; - - if (job.preset.vbrQuality >= 0 && job.preset.codecName == "libmp3lame") { - av_opt_set_int(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0); - encCtx.p->flags |= AV_CODEC_FLAG_QSCALE; - } - if (job.preset.vbrQuality >= 0 && job.preset.codecName == "libvorbis") - av_opt_set_double(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0); - - if (job.preset.compression > 0 && job.preset.codecName == "flac") - av_opt_set_int(encCtx.p->priv_data, "compression_level", - job.preset.compression, 0); - - if (avcodec_open2(encCtx.p, encoder, nullptr) < 0) { - job.errorString = "Cannot open encoder"; - return false; - } - - FmtCtxOut out; - avformat_alloc_output_context2(&out.p, nullptr, nullptr, - job.outputPath.toUtf8().constData()); - if (!out.p) { - job.errorString = "Cannot allocate output context"; - return false; - } - - AVStream *outStream = avformat_new_stream(out.p, nullptr); - if (!outStream) { job.errorString = "Cannot create output stream"; return false; } - - avcodec_parameters_from_context(outStream->codecpar, encCtx.p); - outStream->time_base = encCtx.p->time_base; - - // copy metadata tags - av_dict_copy(&out.p->metadata, in.p->metadata, 0); - - if (!(out.p->oformat->flags & AVFMT_NOFILE)) { - if (avio_open(&out.p->pb, job.outputPath.toUtf8().constData(), - AVIO_FLAG_WRITE) < 0) { - job.errorString = "Cannot open output file for writing"; - return false; - } - } - - if (avformat_write_header(out.p, nullptr) < 0) { - job.errorString = "Cannot write output header"; - return false; - } - - SwrGuard swr; - { - AVChannelLayout inLayout{}; - av_channel_layout_copy(&inLayout, &decCtx.p->ch_layout); - - int err = swr_alloc_set_opts2(&swr.p, - &outLayout, encFmt, inSampleRate, - &inLayout, decCtx.p->sample_fmt, inSampleRate, - 0, nullptr); - av_channel_layout_uninit(&inLayout); - - if (err < 0 || swr_init(swr.p) < 0) { - job.errorString = "Cannot initialise resampler"; - return false; - } - } - av_channel_layout_uninit(&outLayout); - - int frameSize = encCtx.p->frame_size; - if (frameSize <= 0) frameSize = 1152; - - FrameGuard encFrame; - encFrame.p->format = encFmt; - encFrame.p->nb_samples = frameSize; - av_channel_layout_copy(&encFrame.p->ch_layout, &encCtx.p->ch_layout); - encFrame.p->sample_rate = inSampleRate; - if (av_frame_get_buffer(encFrame.p, 0) < 0) { - job.errorString = "Cannot allocate encode frame buffer"; - return false; - } - - qint64 ptsOut = 0; - - auto sendFrameToEncoder = [&](AVFrame *f) -> bool { - if (f) { - f->pts = ptsOut; - ptsOut += f->nb_samples; - } - if (avcodec_send_frame(encCtx.p, f) < 0) return false; - - PacketGuard outPkt; - int ret; - while ((ret = avcodec_receive_packet(encCtx.p, outPkt.p)) == 0) { - av_packet_rescale_ts(outPkt.p, encCtx.p->time_base, - outStream->time_base); - outPkt.p->stream_index = 0; - av_interleaved_write_frame(out.p, outPkt.p); - av_packet_unref(outPkt.p); - } - return ret == AVERROR(EAGAIN) || ret == AVERROR_EOF; - }; - - PacketGuard inPkt; - FrameGuard decFrame; - - int bytesPerSample = av_get_bytes_per_sample(encFmt); - bool isPlanar = av_sample_fmt_is_planar(encFmt); - int numPlanes = isPlanar ? inChannels : 1; - int samplesPerPlane_per_frame = isPlanar ? frameSize : frameSize * inChannels; - - std::vector> stageBufs(numPlanes); - - auto flushStage = [&](bool drain) -> bool { - while (true) { - int avail = static_cast(stageBufs[0].size()) / bytesPerSample; - if (isPlanar) { - // avail per plane - } else { - avail /= inChannels; - } - - int needed = drain ? (avail > 0 ? avail : 0) : frameSize; - if (avail < (drain ? 1 : frameSize)) break; - int use = std::min(avail, needed); - - av_frame_make_writable(encFrame.p); - encFrame.p->nb_samples = use; - - for (int pl = 0; pl < numPlanes; ++pl) { - int bytes = use * bytesPerSample * (isPlanar ? 1 : inChannels); - memcpy(encFrame.p->data[pl], stageBufs[pl].data(), bytes); - stageBufs[pl].erase(stageBufs[pl].begin(), - stageBufs[pl].begin() + bytes); - } - - if (!sendFrameToEncoder(encFrame.p)) return false; - } - return true; - }; - - while (!m_stopReq) { - int ret = av_read_frame(in.p, inPkt.p); - if (ret < 0) break; // EOF or error - - if (inPkt.p->stream_index != audioStreamIdx) { - av_packet_unref(inPkt.p); - continue; - } - - avcodec_send_packet(decCtx.p, inPkt.p); - av_packet_unref(inPkt.p); - - while (avcodec_receive_frame(decCtx.p, decFrame.p) == 0) { - int maxOut = swr_get_out_samples(swr.p, decFrame.p->nb_samples); - - std::vector dstPtrs(numPlanes); - for (int pl = 0; pl < numPlanes; ++pl) { - int prevSz = static_cast(stageBufs[pl].size()); - int extSz = maxOut * bytesPerSample * (isPlanar ? 1 : inChannels); - stageBufs[pl].resize(prevSz + extSz); - dstPtrs[pl] = stageBufs[pl].data() + prevSz; - } - - int converted = swr_convert(swr.p, - dstPtrs.data(), maxOut, - const_cast(decFrame.p->data), - decFrame.p->nb_samples); - - for (int pl = 0; pl < numPlanes; ++pl) { - int keepSz = static_cast(stageBufs[pl].size()) - - (maxOut - std::max(converted, 0)) * bytesPerSample - * (isPlanar ? 1 : inChannels); - stageBufs[pl].resize(std::max(keepSz, 0)); - } - - av_frame_unref(decFrame.p); - - if (!flushStage(false)) { - job.errorString = "Encode error during conversion"; - return false; - } - } - } - - if (m_stopReq) { - job.errorString = "Cancelled"; - return false; - } - - avcodec_send_packet(decCtx.p, nullptr); - while (avcodec_receive_frame(decCtx.p, decFrame.p) == 0) { - int maxOut = swr_get_out_samples(swr.p, decFrame.p->nb_samples); - std::vector dstPtrs(numPlanes); - for (int pl = 0; pl < numPlanes; ++pl) { - int prevSz = static_cast(stageBufs[pl].size()); - stageBufs[pl].resize(prevSz + maxOut * bytesPerSample - * (isPlanar ? 1 : inChannels)); - dstPtrs[pl] = stageBufs[pl].data() + prevSz; - } - int converted = swr_convert(swr.p, dstPtrs.data(), maxOut, - const_cast(decFrame.p->data), decFrame.p->nb_samples); - for (int pl = 0; pl < numPlanes; ++pl) { - int keepSz = static_cast(stageBufs[pl].size()) - - (maxOut - std::max(converted, 0)) * bytesPerSample - * (isPlanar ? 1 : inChannels); - stageBufs[pl].resize(std::max(keepSz, 0)); - } - av_frame_unref(decFrame.p); - flushStage(false); - } - - { - int maxOut = swr_get_out_samples(swr.p, 0); - if (maxOut > 0) { - std::vector dstPtrs(numPlanes); - for (int pl = 0; pl < numPlanes; ++pl) { - int prevSz = static_cast(stageBufs[pl].size()); - stageBufs[pl].resize(prevSz + maxOut * bytesPerSample - * (isPlanar ? 1 : inChannels)); - dstPtrs[pl] = stageBufs[pl].data() + prevSz; - } - int converted = swr_convert(swr.p, dstPtrs.data(), maxOut, - nullptr, 0); - for (int pl = 0; pl < numPlanes; ++pl) { - int keepSz = static_cast(stageBufs[pl].size()) - - (maxOut - std::max(converted, 0)) * bytesPerSample - * (isPlanar ? 1 : inChannels); - stageBufs[pl].resize(std::max(keepSz, 0)); - } - } - } - - flushStage(true); - - sendFrameToEncoder(nullptr); - - av_write_trailer(out.p); - return true; -} - -} // namespace engine \ No newline at end of file diff --git a/src/engine/AlbumReencoder.h b/src/engine/AlbumReencoder.h deleted file mode 100644 index 354f6c2..0000000 --- a/src/engine/AlbumReencoder.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include "ReencodeJob.h" - -namespace engine { - -class AlbumReencoder : public QObject { - Q_OBJECT -public: - explicit AlbumReencoder(QObject *parent = nullptr); - ~AlbumReencoder(); - - void setJobs(const QList &jobs); - - void start(); - - void requestStop(); - - bool isRunning() const; - - QList jobs() const; - -signals: - void trackStarted (int index, const QString &displayTitle); - void trackFinished(int index, bool success, const QString &outputPath, - const QString &errorString); - void progressBytes(int index, qint64 bytesWritten, qint64 totalBytes); - void allFinished (int succeeded, int failed); - -private: - void workerRun(); - bool encodeTrack(ReencodeJob &job); - - QList m_jobs; - mutable QMutex m_mtx; - QThread *m_thread { nullptr }; - std::atomic m_stopReq { false }; -}; - -} // namespace engine \ No newline at end of file diff --git a/src/engine/encoder/AlbumReencoder.cpp b/src/engine/encoder/AlbumReencoder.cpp index e28e2b0..a81a673 100644 --- a/src/engine/encoder/AlbumReencoder.cpp +++ b/src/engine/encoder/AlbumReencoder.cpp @@ -9,7 +9,9 @@ AlbumReencoder::~AlbumReencoder() { requestStop(); if (m_thread) { - m_thread->wait(8000); + m_thread->quit(); + m_thread->wait(); + m_thread = nullptr; } } @@ -30,6 +32,12 @@ bool AlbumReencoder::isRunning() const return m_thread && m_thread->isRunning(); } +void AlbumReencoder::wait(unsigned long msecs) +{ + if (m_thread) + m_thread->wait(msecs); +} + void AlbumReencoder::requestStop() { m_stopReq = true; @@ -39,19 +47,20 @@ void AlbumReencoder::start() { if (isRunning()) return; m_stopReq = false; - - m_thread = QThread::create([this]{ workerRun(); }); - m_thread->setObjectName("subwave-reencode"); - - connect(m_thread, &QThread::finished, m_thread, &QObject::deleteLater); - - m_thread->start(QThread::LowPriority); + QThread *thread = QThread::create([this]{ workerRun(); }); + thread->setObjectName("subwave-reencode"); + m_thread = thread; + connect(thread, &QThread::finished, this, [this, thread]{ + if (m_thread == thread) + m_thread = nullptr; + thread->deleteLater(); + }); + thread->start(QThread::LowPriority); } void AlbumReencoder::workerRun() { TrackEncoder encoder(m_stopReq); - int succeeded = 0, failed = 0; QMutexLocker lk(&m_mtx); @@ -61,21 +70,32 @@ void AlbumReencoder::workerRun() for (int i = 0; i < total; ++i) { if (m_stopReq) break; - lk.relock(); - ReencodeJob &job = m_jobs[i]; - job.status = ReencodeJob::Status::Running; - lk.unlock(); + QString title; + { + QMutexLocker innerLk(&m_mtx); + m_jobs[i].status = ReencodeJob::Status::Running; + title = m_jobs[i].track.displayTitle(); + } + emit trackStarted(i, title); - emit trackStarted(i, job.track.displayTitle()); + ReencodeJob jobCopy; + { + QMutexLocker innerLk(&m_mtx); + jobCopy = m_jobs[i]; + } - bool ok = encoder.encode(job); + bool ok = encoder.encode(jobCopy); - lk.relock(); - job.status = ok ? ReencodeJob::Status::Done : ReencodeJob::Status::Failed; - lk.unlock(); + { + QMutexLocker innerLk(&m_mtx); + m_jobs[i].status = ok ? ReencodeJob::Status::Done + : ReencodeJob::Status::Failed; + m_jobs[i].outputPath = jobCopy.outputPath; + m_jobs[i].errorString = jobCopy.errorString; + } if (ok) ++succeeded; else ++failed; - emit trackFinished(i, ok, job.outputPath, job.errorString); + emit trackFinished(i, ok, jobCopy.outputPath, jobCopy.errorString); } emit allFinished(succeeded, failed); diff --git a/src/engine/encoder/AlbumReencoder.h b/src/engine/encoder/AlbumReencoder.h index 8c0d897..d9144b0 100644 --- a/src/engine/encoder/AlbumReencoder.h +++ b/src/engine/encoder/AlbumReencoder.h @@ -1,20 +1,17 @@ #pragma once - #include "ReencodeJob.h" - #include #include #include #include - #include +#include namespace engine { class AlbumReencoder : public QObject { Q_OBJECT - public: explicit AlbumReencoder(QObject *parent = nullptr); ~AlbumReencoder() override; @@ -24,6 +21,7 @@ public: bool isRunning() const; + void wait(unsigned long msecs = ULONG_MAX); public slots: void start(); void requestStop(); diff --git a/src/engine/encoder/CodecUtils.h b/src/engine/encoder/CodecUtils.h new file mode 100644 index 0000000..e892c70 --- /dev/null +++ b/src/engine/encoder/CodecUtils.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include + +extern "C" { +#include +#include +#include +} + +namespace engine { + +// ChannelLayoutGuard +// RAII wrappper to ensure that av_channel_layout_uninit is always called +struct ChannelLayoutGuard { + AVChannelLayout layout{}; + ~ChannelLayoutGuard() { av_channel_layout_uninit(&layout); } + AVChannelLayout *get() { return &layout; } + const AVChannelLayout *get() const { return &layout; } +}; + +inline AVSampleFormat pickSampleFormat(const AVCodec *encoder, + const QString &codecName) +{ + if (!encoder->sample_fmts) + return AV_SAMPLE_FMT_FLTP; + + auto supports = [&](AVSampleFormat fmt) -> bool { + for (int i = 0; encoder->sample_fmts[i] != AV_SAMPLE_FMT_NONE; ++i) + if (encoder->sample_fmts[i] == fmt) return true; + return false; + }; + + if (codecName == "pcm_s16le") + return supports(AV_SAMPLE_FMT_S16) ? AV_SAMPLE_FMT_S16 + : encoder->sample_fmts[0]; + + if (codecName == "pcm_s24le" || codecName == "pcm_s32le") + return supports(AV_SAMPLE_FMT_S32) ? AV_SAMPLE_FMT_S32 + : encoder->sample_fmts[0]; + + if (supports(AV_SAMPLE_FMT_FLTP)) return AV_SAMPLE_FMT_FLTP; + + return encoder->sample_fmts[0]; +} + +inline int pickChannelCount(const QString &codecName, int inChannels) +{ + if (codecName == "libopus") return std::min(inChannels, 2); + if (codecName == "libvorbis") return std::min(inChannels, 8); + return inChannels; +} + +// clampBitrate +// Returns the clamped bitrate in kbps, or -1 if no bitrate is requested +// VBR or lossles passes 0 +inline int64_t clampBitrate(const QString &codecName, + int64_t requestedKbps, + int channels) +{ + if (requestedKbps <= 0) return -1; + + if (codecName == "libopus") { + // Opus spec: 6–510 kbps per channel + const int64_t minTotal = 6LL * channels; + const int64_t maxTotal = 510LL * channels; + return std::clamp(requestedKbps, minTotal, maxTotal); + } + if (codecName == "libmp3lame") + return std::clamp(requestedKbps, INT64_C(8), INT64_C(320)); + + return requestedKbps; +} + +} // namespace engine diff --git a/src/engine/encoder/DecoderPipeline.h b/src/engine/encoder/DecoderPipeline.h new file mode 100644 index 0000000..c220f6e --- /dev/null +++ b/src/engine/encoder/DecoderPipeline.h @@ -0,0 +1,84 @@ +#pragma once + +#include "FfmpegGuards.h" + +#include + +extern "C" { +#include +#include +} + +namespace engine { + + +struct DecoderPipeline +{ + FmtCtxIn fmtCtx; // owns AVFormatContext* + CodecCtxGuard ctx; // owns AVCodecContext* + AVStream *stream = nullptr; // non-owning; lifetime == fmtCtx + + // open filelPath find audiostream and open decoder for it, returns false and sets errorout on any failure + bool open(const QString &filePath, QString *errorOut) + { + AVDictionary *opts = nullptr; + av_dict_set(&opts, "probesize", "5000000", 0); + av_dict_set(&opts, "analyzeduration", "5000000", 0); + int err = avformat_open_input(&fmtCtx.p, + filePath.toUtf8().constData(), + nullptr, &opts); + av_dict_free(&opts); + if (err < 0) { + if (errorOut) *errorOut = "Cannot open input file"; + return false; + } + + if (avformat_find_stream_info(fmtCtx.p, nullptr) < 0) { + if (errorOut) *errorOut = "Cannot read stream info"; + return false; + } + + const int audioIdx = av_find_best_stream( + fmtCtx.p, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); + if (audioIdx < 0) { + if (errorOut) *errorOut = "No audio stream found"; + return false; + } + + stream = fmtCtx.p->streams[audioIdx]; + + const AVCodec *decoder = + avcodec_find_decoder(stream->codecpar->codec_id); + if (!decoder) { + if (errorOut) *errorOut = "No decoder for input codec"; + return false; + } + + ctx.p = avcodec_alloc_context3(decoder); + if (!ctx.p) { + if (errorOut) *errorOut = "OOM: decoder context"; + return false; + } + + avcodec_parameters_to_context(ctx.p, stream->codecpar); + ctx.p->pkt_timebase = stream->time_base; + + if (avcodec_open2(ctx.p, decoder, nullptr) < 0) { + if (errorOut) *errorOut = "Cannot open decoder"; + return false; + } + + return true; + } + + // accessors after successful open() + int audioStreamIndex() const + { + return stream ? static_cast(stream - fmtCtx.p->streams[0]) : -1; + } + + int channels() const { return ctx.p ? ctx.p->ch_layout.nb_channels : 0; } + int sampleRate() const { return ctx.p ? ctx.p->sample_rate : 0; } +}; + +} // namespace engine diff --git a/src/engine/encoder/EncoderPipeline.h b/src/engine/encoder/EncoderPipeline.h new file mode 100644 index 0000000..332268a --- /dev/null +++ b/src/engine/encoder/EncoderPipeline.h @@ -0,0 +1,199 @@ +#pragma once + +#include "FfmpegGuards.h" +#include "CodecUtils.h" +#include "SampleRateUtils.h" +#include "ReencodeJob.h" + +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +} + +namespace engine { + +struct EncoderPipeline +{ + CodecCtxGuard encCtx; // owns encoder AVCodecContext + FmtCtxOut out; // owns output AVFormatContext + SwrGuard swr; // owns SwrContext + FrameGuard encFrame; // owns encoder AVFrame (reused) + ChannelLayoutGuard outLayout; // RAII for AVChannelLayout + + AVStream *outStream = nullptr; // non-owning; lifetime == out + int frameSize = 0; + int outChannels = 0; + AVSampleFormat encFmt = AV_SAMPLE_FMT_NONE; + + using SendFrameFn = std::function; + SendFrameFn sendFrameToEncoder; // wired up by open() + + /** + * @brief Open the encoder, muxer, resampler and pre allocate the encode frame. Returns false and sets *errorOut on failure + * + * @param inChannels = decoder ch_layout.nb_channels + * @param inSampleRate = decoder sample_rate + * @param inSampleFmt = decoder sample_fmt + * @param inChLayout = decoder ch_layout (copied!!) + * @param srcMetadata = AVDictinary* from the input fmt_ctx (copied!!) + */ + bool open(const ReencodeJob &job, + AVDictionary *srcMetadata, + int inChannels, + int inSampleRate, + AVSampleFormat inSampleFmt, + const AVChannelLayout &inChLayout, + QString *errorOut) + { + // locate encoder + const AVCodec *encoder = avcodec_find_encoder_by_name( + job.preset.codecName.toUtf8().constData()); + if (!encoder) { + if (errorOut) + *errorOut = QString("Encoder not found: %1").arg(job.preset.codecName); + return false; + } + + // derive output parameters + const int outSampleRate = pickSampleRate(encoder, inSampleRate); + outChannels = pickChannelCount(job.preset.codecName, inChannels); + encFmt = pickSampleFormat(encoder, job.preset.codecName); + + // build encoder context + encCtx.p = avcodec_alloc_context3(encoder); + if (!encCtx.p) { + if (errorOut) *errorOut = "OOM: encoder context"; + return false; + } + + av_channel_layout_default(outLayout.get(), outChannels); + av_channel_layout_copy(&encCtx.p->ch_layout, outLayout.get()); + + encCtx.p->sample_rate = outSampleRate; + encCtx.p->sample_fmt = encFmt; + encCtx.p->time_base = { 1, outSampleRate }; + + { + const int64_t clamped = + clampBitrate(job.preset.codecName, job.preset.bitrate, outChannels); + if (clamped > 0) + encCtx.p->bit_rate = clamped * 1000; + } + + if (job.preset.vbrQuality >= 0) { + if (job.preset.codecName == "libmp3lame") { + av_opt_set_int(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0); + encCtx.p->flags |= AV_CODEC_FLAG_QSCALE; + } else if (job.preset.codecName == "libvorbis") { + av_opt_set_double(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0); + } + } + + if (job.preset.compression > 0 && job.preset.codecName == "flac") + av_opt_set_int(encCtx.p->priv_data, "compression_level", + job.preset.compression, 0); + + if (avcodec_open2(encCtx.p, encoder, nullptr) < 0) { + if (errorOut) *errorOut = "Cannot open encoder"; + return false; + } + + // output muxer + avformat_alloc_output_context2( + &out.p, nullptr, nullptr, + job.outputPath.toUtf8().constData()); + if (!out.p) { + if (errorOut) *errorOut = "Cannot allocate output context"; + return false; + } + + outStream = avformat_new_stream(out.p, nullptr); + if (!outStream) { + if (errorOut) *errorOut = "Cannot create output stream"; + return false; + } + + avcodec_parameters_from_context(outStream->codecpar, encCtx.p); + outStream->time_base = encCtx.p->time_base; + av_dict_copy(&out.p->metadata, srcMetadata, 0); + + if (!(out.p->oformat->flags & AVFMT_NOFILE)) { + if (avio_open(&out.p->pb, + job.outputPath.toUtf8().constData(), + AVIO_FLAG_WRITE) < 0) { + if (errorOut) *errorOut = "Cannot open output file for writing"; + return false; + } + } + + if (avformat_write_header(out.p, nullptr) < 0) { + if (errorOut) *errorOut = "Cannot write output header"; + return false; + } + + // resampler + { + AVChannelLayout inLayout{}; + av_channel_layout_copy(&inLayout, &inChLayout); + const int err = swr_alloc_set_opts2( + &swr.p, + outLayout.get(), encFmt, outSampleRate, + &inLayout, inSampleFmt, inSampleRate, + 0, nullptr); + av_channel_layout_uninit(&inLayout); + + if (err < 0 || swr_init(swr.p) < 0) { + if (errorOut) *errorOut = "Cannot initialise resampler"; + return false; + } + } + + // encoder frame + frameSize = (encCtx.p->frame_size > 0) ? encCtx.p->frame_size : 1152; + + encFrame.p->format = encFmt; + encFrame.p->nb_samples = frameSize; + encFrame.p->sample_rate = outSampleRate; + av_channel_layout_copy(&encFrame.p->ch_layout, &encCtx.p->ch_layout); + if (av_frame_get_buffer(encFrame.p, 0) < 0) { + if (errorOut) *errorOut = "Cannot allocate encode frame buffer"; + return false; + } + + // ptsOut is captured by value and incremented each call, giving each + // frame a monotonically increasing presentation timestamp + qint64 ptsOut = 0; + sendFrameToEncoder = [this, ptsOut](AVFrame *f) mutable -> bool { + if (f) { + f->pts = ptsOut; + ptsOut += f->nb_samples; + } + if (avcodec_send_frame(encCtx.p, f) < 0) return false; + + PacketGuard outPkt; + int ret; + while ((ret = avcodec_receive_packet(encCtx.p, outPkt.p)) == 0) { + av_packet_rescale_ts(outPkt.p, + encCtx.p->time_base, + outStream->time_base); + outPkt.p->stream_index = 0; + av_interleaved_write_frame(out.p, outPkt.p); + av_packet_unref(outPkt.p); + } + return ret == AVERROR(EAGAIN) || ret == AVERROR_EOF; + }; + + return true; + } + + // must be called after the last frame has been sent + void writeTrailer() { av_write_trailer(out.p); } +}; + +} // namespace engine diff --git a/src/engine/encoder/OutputPathBuilder.h b/src/engine/encoder/OutputPathBuilder.h new file mode 100644 index 0000000..923941d --- /dev/null +++ b/src/engine/encoder/OutputPathBuilder.h @@ -0,0 +1,24 @@ +#pragma once + +#include "ReencodeJob.h" + +#include +#include +#include + +namespace engine { + + +inline QString buildOutputPath(const ReencodeJob &job) +{ + QString outDir = job.outputDir; + if (outDir.isEmpty()) + outDir = QFileInfo(job.track.filePath).absolutePath(); + + QDir().mkpath(outDir); + + const QString base = QFileInfo(job.track.filePath).completeBaseName(); + return outDir + QDir::separator() + base + '.' + job.preset.extension; +} + +} // namespace engine diff --git a/src/engine/encoder/SampleRateUtils.h b/src/engine/encoder/SampleRateUtils.h index 0036568..b05cdca 100644 --- a/src/engine/encoder/SampleRateUtils.h +++ b/src/engine/encoder/SampleRateUtils.h @@ -6,15 +6,24 @@ extern "C" { namespace engine { +/** + * @brief Selection Priority: + * 1. Exact match with sourceRate + * 2. Lowest supported rate that is >= sourceRate (upsample as little as possible) + * 3. Highest supported rate overall + * rates are below sourceRate, eg a low rate phone codec at 8khz + */ inline int pickSampleRate(const AVCodec *encoder, int sourceRate) { if (!encoder->supported_samplerates) return sourceRate; + // 1: exact match for (const int *r = encoder->supported_samplerates; *r != 0; ++r) if (*r == sourceRate) return sourceRate; + // 2: lowest rate >= sourceRate (minimal upsampling) int best = 0; for (const int *r = encoder->supported_samplerates; *r != 0; ++r) { if (*r >= sourceRate) { @@ -25,6 +34,7 @@ inline int pickSampleRate(const AVCodec *encoder, int sourceRate) if (best != 0) return best; + // 3: all supported rates are below sourceRate for (const int *r = encoder->supported_samplerates; *r != 0; ++r) if (*r > best) best = *r; @@ -32,4 +42,4 @@ inline int pickSampleRate(const AVCodec *encoder, int sourceRate) return best; } -} // namespace engine \ No newline at end of file +} // namespace engine diff --git a/src/engine/encoder/StageBuffer.cpp b/src/engine/encoder/StageBuffer.cpp index 695182b..c53874a 100644 --- a/src/engine/encoder/StageBuffer.cpp +++ b/src/engine/encoder/StageBuffer.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -38,26 +39,27 @@ int StageBuffer::bytesForSamples(int samples, int /*plane*/) const /** * @brief Resample and append audio to the staging buffer - * - * Input samples are converted using the swrcontext and append + * + * Input samples are converted using the SwrContext and appended * to the accumulation buffers. No encoder frames are emitted by - * this function; callers should invoke flush() to emit output frames - * - * @param srcData Input audio buffers in the format expected by - * the swrcontext - * @param nbSamples Number of input samples per channel - * - * @return true if conversion succeeded ; false if swr_convert() failed - * @note The number of samples may differ from the number of input samples due - * to resampling - * + * this function; callers should invoke flush() to emit output frames. + * + * Passing srcData=nullptr / nbSamples=0 flushes the SWR internal + * delay buffer (end-of-stream drain). + * + * @param srcData Input audio buffers (nullptr to drain SWR latency) + * @param nbSamples Number of input samples per channel (0 when draining) + * + * @return true on success; false if swr_convert() returned an error + * * @author rattatwinko * @date 25/06/2026 */ bool StageBuffer::push(const uint8_t * const *srcData, int nbSamples) { int maxOut = swr_get_out_samples(m_swr, nbSamples); - if (maxOut <= 0) return true; // nothing to do + if (maxOut <= 0) maxOut = (nbSamples == 0) ? 4096 : 0; + if (maxOut <= 0) return true; std::vector dstPtrs(m_numPlanes); for (int pl = 0; pl < m_numPlanes; ++pl) { @@ -80,48 +82,76 @@ bool StageBuffer::push(const uint8_t * const *srcData, int nbSamples) } /** - * @brief Emit buffered samples as a encoder frame - * - * In normal mode (drain=false), frames are emitted only when least - * framesize samples are available. - * + * @brief Emit buffered samples as encoder frames + * + * In normal mode (drain=false), frames are emitted only when at least + * frameSize samples are available. + * * In drain mode (drain=true), all remaining samples are emitted, - * including a final partial frame. this is typically used at eos - * - * foreach emitted frame: - * - the encoder frame is made writeable. - * - samples are copied from the buffers - * - the callback supplied at construction is invoked - * - * @param drain Wether to flush partial frames. - * @return true if the frames callback reports failure, true otherwise - * + * including a final partial frame. This is typically used at EOS. + * + * For each emitted frame: + * - the encoder frame is made writable. + * - samples are copied from the staging buffers. + * - the callback supplied at construction is invoked. + * + * @param drain Whether to flush partial frames. + * @return true on success; false if any step fails. + * * @author rattatwinko * @date 25/06/2026 */ bool StageBuffer::flush(bool drain) { while (true) { - int avail = static_cast(m_bufs[0].size()) - / (m_bytesPerSample * (m_isPlanar ? 1 : m_channels)); + Q_ASSERT(m_encFrame); - if (avail < (drain ? 1 : m_frameSize)) + int availSamples = static_cast( + m_bufs[0].size() / + (m_bytesPerSample * (m_isPlanar ? 1 : m_channels)) + ); + + if (availSamples <= 0) break; - int use = drain ? avail : m_frameSize; + if (!drain && availSamples < m_frameSize) + break; - av_frame_make_writable(m_encFrame); - m_encFrame->nb_samples = use; + int useSamples = drain ? availSamples : m_frameSize; + + if (av_frame_make_writable(m_encFrame) < 0) + return false; + + m_encFrame->nb_samples = useSamples; for (int pl = 0; pl < m_numPlanes; ++pl) { - int bytes = bytesForSamples(use, pl); - std::memcpy(m_encFrame->data[pl], m_bufs[pl].data(), bytes); - m_bufs[pl].erase(m_bufs[pl].begin(), m_bufs[pl].begin() + bytes); + + const int bytes = bytesForSamples(useSamples, pl); + const uint8_t* src = m_bufs[pl].data(); + + if (!src || bytes <= 0) + return false; + + std::memcpy(m_encFrame->data[pl], src, bytes); } if (!m_sendFrame(m_encFrame)) return false; + + for (int pl = 0; pl < m_numPlanes; ++pl) { + + const int bytes = bytesForSamples(useSamples, pl); + + if (bytes > static_cast(m_bufs[pl].size())) + return false; + + m_bufs[pl].erase( + m_bufs[pl].begin(), + m_bufs[pl].begin() + bytes + ); + } } + return true; } diff --git a/src/engine/encoder/TrackEncoder.cpp b/src/engine/encoder/TrackEncoder.cpp index 020cbae..b7b7e94 100644 --- a/src/engine/encoder/TrackEncoder.cpp +++ b/src/engine/encoder/TrackEncoder.cpp @@ -1,274 +1,73 @@ #include "TrackEncoder.h" -#include "FfmpegGuards.h" +#include "DecoderPipeline.h" +#include "EncoderPipeline.h" +#include "OutputPathBuilder.h" #include "StageBuffer.h" -#include "SampleRateUtils.h" - -#include -#include - -extern "C" { -#include -#include -#include -#include -#include -#include -} namespace engine { -namespace { - -QString buildOutputPath(const ReencodeJob &job) -{ - QString outDir = job.outputDir; - if (outDir.isEmpty()) - outDir = QFileInfo(job.track.filePath).absolutePath(); - - QDir().mkpath(outDir); - - QString base = QFileInfo(job.track.filePath).completeBaseName(); - return outDir + QDir::separator() + base + "." + job.preset.extension; -} - -AVSampleFormat pickSampleFormat(const AVCodec *encoder, const QString &codecName) -{ - if (!encoder->sample_fmts) - return AV_SAMPLE_FMT_FLTP; - - auto supports = [&](AVSampleFormat fmt) -> bool { - for (int i = 0; encoder->sample_fmts[i] != AV_SAMPLE_FMT_NONE; ++i) - if (encoder->sample_fmts[i] == fmt) return true; - return false; - }; - - if (codecName == "pcm_s16le") - return supports(AV_SAMPLE_FMT_S16) ? AV_SAMPLE_FMT_S16 - : encoder->sample_fmts[0]; - - if (codecName == "pcm_s24le" || codecName == "pcm_s32le") - return supports(AV_SAMPLE_FMT_S32) ? AV_SAMPLE_FMT_S32 - : encoder->sample_fmts[0]; - - if (supports(AV_SAMPLE_FMT_FLTP)) return AV_SAMPLE_FMT_FLTP; - - return encoder->sample_fmts[0]; -} - -int pickChannelCount(const QString &codecName, int inChannels) -{ - if (codecName == "libopus") - return std::min(inChannels, 2); - if (codecName == "libvorbis") - return std::min(inChannels, 8); - return inChannels; -} - -int64_t clampBitrate(const QString &codecName, int64_t requestedKbps, int channels) -{ - if (requestedKbps <= 0) return -1; - - if (codecName == "libopus") { - // opus: 6–510 kbps per channel - int64_t minTotal = 6LL * channels; - int64_t maxTotal = 510LL * channels; - return std::clamp(requestedKbps, minTotal, maxTotal); - } - if (codecName == "libmp3lame") { - return std::clamp(requestedKbps, INT64_C(8), INT64_C(320)); - } - return requestedKbps; -} - -} // anonymous namespace - TrackEncoder::TrackEncoder(const std::atomic &stopFlag) : m_stop(stopFlag) {} +/** + * @brief DecoderPipeline -> StageBuffer -> EncoderPipeline and runs read, decode, resample, encode loop + * + * All resource managment (FFmpeg context, frames, packets) is handled by RAII types inside + * DecoderPipeline and EncoderPipeline, this function only owns the loop logic + */ bool TrackEncoder::encode(ReencodeJob &job) const { job.outputPath = buildOutputPath(job); job.errorString = {}; - FmtCtxIn in; - { - AVDictionary *opts = nullptr; - av_dict_set(&opts, "probesize", "5000000", 0); - av_dict_set(&opts, "analyzeduration", "5000000", 0); - int err = avformat_open_input(&in.p, - job.track.filePath.toUtf8().constData(), - nullptr, &opts); - av_dict_free(&opts); - if (err < 0) { job.errorString = "Cannot open input file"; return false; } - } - if (avformat_find_stream_info(in.p, nullptr) < 0) { - job.errorString = "Cannot read stream info"; return false; - } - - int audioIdx = av_find_best_stream(in.p, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); - if (audioIdx < 0) { job.errorString = "No audio stream found"; return false; } - - AVStream *inStream = in.p->streams[audioIdx]; - - const AVCodec *decoder = avcodec_find_decoder(inStream->codecpar->codec_id); - if (!decoder) { job.errorString = "No decoder for input codec"; return false; } - - CodecCtxGuard decCtx; - decCtx.p = avcodec_alloc_context3(decoder); - if (!decCtx.p) { job.errorString = "OOM: decoder context"; return false; } - - avcodec_parameters_to_context(decCtx.p, inStream->codecpar); - - decCtx.p->pkt_timebase = inStream->time_base; - if (avcodec_open2(decCtx.p, decoder, nullptr) < 0) { - job.errorString = "Cannot open decoder"; return false; - } - - const int inChannels = decCtx.p->ch_layout.nb_channels; - const int inSampleRate = decCtx.p->sample_rate; - - const AVCodec *encoder = avcodec_find_encoder_by_name( - job.preset.codecName.toUtf8().constData()); - if (!encoder) { - job.errorString = QString("Encoder not found: %1").arg(job.preset.codecName); + // open input and decoder + DecoderPipeline dec; + if (!dec.open(job.track.filePath, &job.errorString)) return false; - } - // opus only accepts 8/12/16/24/48 kHz; 44100 must become 48000. - const int outSampleRate = pickSampleRate(encoder, inSampleRate); + const int audioStreamIdx = static_cast( + dec.stream - dec.fmtCtx.p->streams[0]); - const int outChannels = pickChannelCount(job.preset.codecName, inChannels); + // open encoder, muxer, resampler, encode frame + EncoderPipeline enc; + if (!enc.open(job, + dec.fmtCtx.p->metadata, + dec.channels(), + dec.sampleRate(), + dec.ctx.p->sample_fmt, + dec.ctx.p->ch_layout, + &job.errorString)) + return false; - const AVSampleFormat encFmt = pickSampleFormat(encoder, job.preset.codecName); - - CodecCtxGuard encCtx; - encCtx.p = avcodec_alloc_context3(encoder); - if (!encCtx.p) { job.errorString = "OOM: encoder context"; return false; } - - AVChannelLayout outLayout{}; - av_channel_layout_default(&outLayout, outChannels); - av_channel_layout_copy(&encCtx.p->ch_layout, &outLayout); - - encCtx.p->sample_rate = outSampleRate; - encCtx.p->sample_fmt = encFmt; - encCtx.p->time_base = { 1, outSampleRate }; - - { - int64_t clamped = clampBitrate(job.preset.codecName, - job.preset.bitrate, outChannels); - if (clamped > 0) - encCtx.p->bit_rate = clamped * 1000; - } - - if (job.preset.vbrQuality >= 0) { - if (job.preset.codecName == "libmp3lame") { - av_opt_set_int(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0); - encCtx.p->flags |= AV_CODEC_FLAG_QSCALE; - } else if (job.preset.codecName == "libvorbis") { - av_opt_set_double(encCtx.p->priv_data, "q", job.preset.vbrQuality, 0); - } - } - - if (job.preset.compression > 0 && job.preset.codecName == "flac") - av_opt_set_int(encCtx.p->priv_data, "compression_level", - job.preset.compression, 0); - - if (avcodec_open2(encCtx.p, encoder, nullptr) < 0) { - job.errorString = "Cannot open encoder"; return false; - } - - FmtCtxOut out; - avformat_alloc_output_context2(&out.p, nullptr, nullptr, - job.outputPath.toUtf8().constData()); - if (!out.p) { job.errorString = "Cannot allocate output context"; return false; } - - AVStream *outStream = avformat_new_stream(out.p, nullptr); - if (!outStream) { job.errorString = "Cannot create output stream"; return false; } - - avcodec_parameters_from_context(outStream->codecpar, encCtx.p); - outStream->time_base = encCtx.p->time_base; - - av_dict_copy(&out.p->metadata, in.p->metadata, 0); - - if (!(out.p->oformat->flags & AVFMT_NOFILE)) { - if (avio_open(&out.p->pb, job.outputPath.toUtf8().constData(), - AVIO_FLAG_WRITE) < 0) { - job.errorString = "Cannot open output file for writing"; return false; - } - } - - if (avformat_write_header(out.p, nullptr) < 0) { - job.errorString = "Cannot write output header"; return false; - } - - SwrGuard swr; - { - AVChannelLayout inLayout{}; - av_channel_layout_copy(&inLayout, &decCtx.p->ch_layout); - int err = swr_alloc_set_opts2(&swr.p, - &outLayout, encFmt, outSampleRate, - &inLayout, decCtx.p->sample_fmt, inSampleRate, - 0, nullptr); - av_channel_layout_uninit(&inLayout); - - if (err < 0 || swr_init(swr.p) < 0) { - job.errorString = "Cannot initialise resampler"; return false; - } - } - av_channel_layout_uninit(&outLayout); - - const int frameSize = (encCtx.p->frame_size > 0) ? encCtx.p->frame_size : 1152; - - FrameGuard encFrame; - encFrame.p->format = encFmt; - encFrame.p->nb_samples = frameSize; - encFrame.p->sample_rate = outSampleRate; - av_channel_layout_copy(&encFrame.p->ch_layout, &encCtx.p->ch_layout); - if (av_frame_get_buffer(encFrame.p, 0) < 0) { - job.errorString = "Cannot allocate encode frame buffer"; return false; - } - - qint64 ptsOut = 0; - - auto sendFrameToEncoder = [&](AVFrame *f) -> bool { - if (f) { - f->pts = ptsOut; - ptsOut += f->nb_samples; - } - if (avcodec_send_frame(encCtx.p, f) < 0) return false; - - PacketGuard outPkt; - int ret; - while ((ret = avcodec_receive_packet(encCtx.p, outPkt.p)) == 0) { - av_packet_rescale_ts(outPkt.p, encCtx.p->time_base, outStream->time_base); - outPkt.p->stream_index = 0; - av_interleaved_write_frame(out.p, outPkt.p); - av_packet_unref(outPkt.p); - } - return ret == AVERROR(EAGAIN) || ret == AVERROR_EOF; - }; - - StageBuffer stage(swr.p, encFrame.p, encFmt, outChannels, frameSize, - sendFrameToEncoder); + // staging buffer + StageBuffer stage(enc.swr.p, + enc.encFrame.p, + enc.encFmt, + enc.outChannels, + enc.frameSize, + enc.sendFrameToEncoder); + // main read, decode, encode loop PacketGuard inPkt; FrameGuard decFrame; while (!m_stop) { - int ret = av_read_frame(in.p, inPkt.p); + const int ret = av_read_frame(dec.fmtCtx.p, inPkt.p); if (ret < 0) break; - if (inPkt.p->stream_index != audioIdx) { + if (inPkt.p->stream_index != audioStreamIdx) { av_packet_unref(inPkt.p); continue; } - av_packet_rescale_ts(inPkt.p, inStream->time_base, decCtx.p->pkt_timebase); - avcodec_send_packet(decCtx.p, inPkt.p); + av_packet_rescale_ts(inPkt.p, + dec.stream->time_base, + dec.ctx.p->pkt_timebase); + avcodec_send_packet(dec.ctx.p, inPkt.p); av_packet_unref(inPkt.p); - while (avcodec_receive_frame(decCtx.p, decFrame.p) == 0) { + while (avcodec_receive_frame(dec.ctx.p, decFrame.p) == 0) { if (!stage.push(const_cast(decFrame.p->data), decFrame.p->nb_samples)) { job.errorString = "SWR convert error"; @@ -285,21 +84,37 @@ bool TrackEncoder::encode(ReencodeJob &job) const if (m_stop) { job.errorString = "Cancelled"; return false; } - avcodec_send_packet(decCtx.p, nullptr); - while (avcodec_receive_frame(decCtx.p, decFrame.p) == 0) { - stage.push(const_cast(decFrame.p->data), - decFrame.p->nb_samples); + // drain decoder + avcodec_send_packet(dec.ctx.p, nullptr); + while (avcodec_receive_frame(dec.ctx.p, decFrame.p) == 0) { + if (!stage.push(const_cast(decFrame.p->data), + decFrame.p->nb_samples)) { + job.errorString = "SWR convert error (drain)"; + return false; + } av_frame_unref(decFrame.p); - stage.flush(false); + + if (!stage.flush(false)) { + job.errorString = "Encode error (drain)"; + return false; + } } - stage.push(nullptr, 0); + // flush SWR internal latency buffer + if (!stage.push(nullptr, 0)) { + job.errorString = "SWR flush error"; + return false; + } - stage.flush(true); + if (!stage.flush(true)) { + job.errorString = "Encode error (final flush)"; + return false; + } - sendFrameToEncoder(nullptr); + // flush encoder + enc.sendFrameToEncoder(nullptr); + enc.writeTrailer(); - av_write_trailer(out.p); return true; } diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 4834cc0..f98269b 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -23,6 +23,8 @@ #include #include #include +#include +#include MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) @@ -40,6 +42,8 @@ MainWindow::MainWindow(QWidget *parent) m_uiTimer.setInterval(500); connect(&m_uiTimer, &QTimer::timeout, this, [this] { auto *e = m_playback->engine(); + if (!e) return; + m_nowPlaying->updatePosition( e->positionFrames(), e->totalFrames(), @@ -50,58 +54,75 @@ MainWindow::MainWindow(QWidget *parent) }); m_uiTimer.start(); - m_library->scan(); + QMetaObject::invokeMethod(m_library, &LibraryController::scan, + Qt::QueuedConnection); } MainWindow::~MainWindow() { - m_eqWidget->saveState(); - m_playback->stop(); - m_library->library()->shutdown(); + shutdown(); +} + +void MainWindow::shutdown() +{ + if (m_shutdown) return; + m_shutdown = true; + + m_uiTimer.stop(); + + if (m_eqWidget) + m_eqWidget->saveState(); + + if (m_playback) + m_playback->stop(); + + if (m_library && m_library->library()) + m_library->library()->shutdown(); } void MainWindow::closeEvent(QCloseEvent *e) { - m_eqWidget->saveState(); - m_playback->stop(); - m_library->library()->shutdown(); + shutdown(); e->accept(); } void MainWindow::buildUI() { - auto *central = new QWidget; + auto *central = new QWidget(this); auto *mainVlay = new QVBoxLayout(central); mainVlay->setSpacing(4); mainVlay->setContentsMargins(4, 4, 4, 4); setCentralWidget(central); - m_nowPlaying = new NowPlayingPanel; + m_nowPlaying = new NowPlayingPanel(central); mainVlay->addWidget(m_nowPlaying); - m_spectrogram = new SpectrogramWidget; + m_spectrogram = new SpectrogramWidget(central); mainVlay->addWidget(m_spectrogram, 1); - auto *tabs = new QTabWidget; + auto *tabs = new QTabWidget(central); - auto *plPanel = new QWidget; + auto *plPanel = new QWidget(tabs); auto *plVlay = new QVBoxLayout(plPanel); plVlay->setContentsMargins(0, 2, 0, 0); - m_playlistView = new QListView; + m_playlistView = new QListView(plPanel); m_playlistView->setModel(m_library->model()); - m_playlistView->setItemDelegate(new TrackDelegate(this)); + m_playlistView->setItemDelegate(new TrackDelegate(m_playlistView)); m_playlistView->setSelectionMode(QAbstractItemView::SingleSelection); m_playlistView->setUniformItemSizes(true); m_playlistView->setAlternatingRowColors(true); m_playlistView->setMinimumHeight(220); - m_lblStatus = new QLabel("No library loaded."); + m_lblStatus = new QLabel("No library loaded.", plPanel); plVlay->addWidget(m_playlistView); plVlay->addWidget(m_lblStatus); tabs->addTab(plPanel, "Playlist"); - m_eqWidget = new EqualizerWidget(m_playback->engine()); + auto *engine = m_playback->engine(); + Q_ASSERT_X(engine, "MainWindow::buildUI", + "PlaybackController must provide a valid engine before buildUI"); + m_eqWidget = new EqualizerWidget(engine, tabs); tabs->addTab(m_eqWidget, "Equalizer"); mainVlay->addWidget(tabs); @@ -117,13 +138,13 @@ void MainWindow::buildMenuBar() auto *chooseAct = file->addAction("Choose Music Location..."); chooseAct->setShortcut(QKeySequence::Open); connect(chooseAct, &QAction::triggered, this, [this] { - QString dir = QFileDialog::getExistingDirectory( + const QString dir = QFileDialog::getExistingDirectory( this, "Select Music Folder", m_library->library()->rootPath(), QFileDialog::ShowDirsOnly); if (dir.isEmpty()) return; m_library->setRootPath(dir); - setWindowTitle("SubWave - " + QDir(dir).dirName()); + setWindowTitle("SubWave — " + QDir(dir).dirName()); m_library->scan(); }); @@ -133,10 +154,12 @@ void MainWindow::buildMenuBar() file->addSeparator(); - m_reencodeAct = file->addAction("Reencode Album…"); - m_reencodeAct->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R)); - m_reencodeAct->setEnabled(false); - connect(m_reencodeAct, &QAction::triggered, this, &MainWindow::openReencodeDialog); + m_reencodeAct = file->addAction("Reencode Album\u2026"); + m_reencodeAct->setShortcut( + QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R)); + m_reencodeAct->setEnabled(false); + connect(m_reencodeAct, &QAction::triggered, + this, &MainWindow::openReencodeDialog); file->addSeparator(); @@ -145,9 +168,9 @@ void MainWindow::buildMenuBar() connect(quitAct, &QAction::triggered, qApp, &QApplication::quit); auto *plMenu = mb->addMenu("&Playlists"); - connect(plMenu->addAction("Create Playlist…"), &QAction::triggered, + connect(plMenu->addAction("Create Playlist\u2026"), &QAction::triggered, m_library, &LibraryController::createPlaylist); - connect(plMenu->addAction("Load Playlist…"), &QAction::triggered, + connect(plMenu->addAction("Load Playlist\u2026"), &QAction::triggered, m_library, &LibraryController::loadPlaylist); auto *help = mb->addMenu("&Help"); @@ -155,8 +178,8 @@ void MainWindow::buildMenuBar() connect(about, &QAction::triggered, this, [this] { QMessageBox::about(this, "SubWave", "SubWave\n" - "Its a music player.\n\n" - "Thats it.\n" + "It's a music player.\n\n" + "That's it.\n" "~/.subwave/"); }); } @@ -174,38 +197,32 @@ void MainWindow::wireSignals() connect(m_nowPlaying, &NowPlayingPanel::volumeChanged, m_playback, &PlaybackController::setVolume); - // shuffle / repeat checkboxes -> PlaybackController connect(m_nowPlaying->shuffleCheckBox(), &QCheckBox::toggled, m_playback, &PlaybackController::setShuffle); connect(m_nowPlaying->repeatCheckBox(), &QCheckBox::toggled, m_playback, &PlaybackController::setRepeat); - // PlaybackController -> NowPlayingPanel Spectrogram connect(m_playback, &PlaybackController::playbackStarted, - m_nowPlaying, [this]{ m_nowPlaying->setPlaybackActive(true); }); + m_nowPlaying, [this] { m_nowPlaying->setPlaybackActive(true); }); connect(m_playback, &PlaybackController::playbackPaused, - m_nowPlaying, [this]{ m_nowPlaying->setPlaybackActive(false); }); + m_nowPlaying, [this] { m_nowPlaying->setPlaybackActive(false); }); connect(m_playback, &PlaybackController::playbackStopped, - m_nowPlaying, [this]{ m_nowPlaying->setPlaybackActive(false); }); + m_nowPlaying, [this] { m_nowPlaying->setPlaybackActive(false); }); + connect(m_playback, &PlaybackController::fftReady, m_spectrogram, &SpectrogramWidget::receiveFft, Qt::QueuedConnection); - // PlaybackController -> LibraryController (resolve track index) connect(m_playback, &PlaybackController::playRequested, m_library, &LibraryController::onPlayRequested); - // LibraryController -> NowPlayingPanel (new track metadata) connect(m_library, &LibraryController::trackChanged, m_nowPlaying, [this](const engine::Track *t, int) { m_nowPlaying->updateTrack(t); m_spectrogram->reset(); m_playlistView->scrollTo( m_library->model()->index(m_playback->currentIndex())); - - m_reencodeAct->setEnabled(t != nullptr); }); - // LibraryController -> ui status / track count connect(m_library, &LibraryController::statusMessage, m_lblStatus, &QLabel::setText); connect(m_library, &LibraryController::trackCountChanged, @@ -232,18 +249,20 @@ void MainWindow::openReencodeDialog() } QString albumFilter; - - int currentIdx = m_playback->currentIndex(); + const int currentIdx = m_playback->currentIndex(); if (currentIdx >= 0 && currentIdx < all.size()) { albumFilter = all[currentIdx].album; } else { - QModelIndex sel = m_playlistView->currentIndex(); + const QModelIndex sel = m_playlistView->currentIndex(); if (sel.isValid() && sel.row() < all.size()) albumFilter = all[sel.row()].album; } QList targets; - if (!albumFilter.isEmpty() && albumFilter != "Unknown Album") { + const bool validAlbum = + !albumFilter.isEmpty() && albumFilter != "Unknown Album"; + + if (validAlbum) { for (const auto &t : all) if (t.album.compare(albumFilter, Qt::CaseInsensitive) == 0) targets.append(t); @@ -253,9 +272,8 @@ void MainWindow::openReencodeDialog() targets = all; ReencodeDialog dlg(targets, this); - - if (!albumFilter.isEmpty() && albumFilter != "Unknown Album") - dlg.setWindowTitle(QString("Reencode Album — %1").arg(albumFilter)); + if (validAlbum) + dlg.setWindowTitle(QString("Reencode Album \u2014 %1").arg(albumFilter)); dlg.exec(); } @@ -269,11 +287,11 @@ void MainWindow::keyPressEvent(QKeyEvent *e) case Qt::Key_Space: m_playback->togglePlayPause(); e->accept(); return; case Qt::Key_MediaNext: - m_playback->playNext(); e->accept(); return; + m_playback->playNext(); e->accept(); return; case Qt::Key_MediaPrevious: - m_playback->playPrev(); e->accept(); return; + m_playback->playPrev(); e->accept(); return; case Qt::Key_MediaStop: - m_playback->stop(); e->accept(); return; + m_playback->stop(); e->accept(); return; default: QMainWindow::keyPressEvent(e); } diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 6a67c16..8773f3b 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -1,4 +1,6 @@ +// MainWindow.h #pragma once + #include #include @@ -11,11 +13,13 @@ class QListView; class QLabel; class QAction; -class MainWindow : public QMainWindow { +class MainWindow : public QMainWindow +{ Q_OBJECT + public: explicit MainWindow(QWidget *parent = nullptr); - ~MainWindow(); + ~MainWindow() override; protected: void closeEvent(QCloseEvent *e) override; @@ -28,17 +32,16 @@ private: void buildUI(); void buildMenuBar(); void wireSignals(); + void shutdown(); - PlaybackController *m_playback { nullptr }; - LibraryController *m_library { nullptr }; - + PlaybackController *m_playback { nullptr }; + LibraryController *m_library { nullptr }; NowPlayingPanel *m_nowPlaying { nullptr }; SpectrogramWidget *m_spectrogram { nullptr }; EqualizerWidget *m_eqWidget { nullptr }; QListView *m_playlistView{ nullptr }; QLabel *m_lblStatus { nullptr }; - QAction *m_reencodeAct { nullptr }; - - QTimer m_uiTimer; + QTimer m_uiTimer; + bool m_shutdown { false }; }; \ No newline at end of file diff --git a/src/gui/ReencodeDialog.cpp b/src/gui/ReencodeDialog.cpp index 2ace44c..acbc0ab 100644 --- a/src/gui/ReencodeDialog.cpp +++ b/src/gui/ReencodeDialog.cpp @@ -1,5 +1,5 @@ #include "ReencodeDialog.h" -#include "engine/AlbumReencoder.h" +#include "engine/encoder/AlbumReencoder.h" #include #include @@ -15,10 +15,9 @@ #include #include #include -#include #include -enum Col { ColArtist=0, ColTitle, ColStatus, ColOutput, COL_COUNT }; +enum Col { ColArtist = 0, ColTitle, ColStatus, ColOutput, COL_COUNT }; ReencodeDialog::ReencodeDialog(const QList &tracks, QWidget *parent) : QDialog(parent) @@ -30,7 +29,6 @@ ReencodeDialog::ReencodeDialog(const QList &tracks, QWidget *pare setMinimumSize(820, 560); buildUi(); populateTrackTable(); - onPresetChanged(0); connect(m_reencoder, &engine::AlbumReencoder::trackStarted, this, &ReencodeDialog::onTrackStarted, Qt::QueuedConnection); @@ -56,15 +54,21 @@ void ReencodeDialog::buildUi() settingsGrid->setColumnStretch(1, 1); settingsGrid->addWidget(new QLabel("Preset:", this), 0, 0); - m_presetCombo = new QComboBox(this); - for (const auto &p : m_presets) - m_presetCombo->addItem(p.label); - settingsGrid->addWidget(m_presetCombo, 0, 1, 1, 2); + m_presetCombo = new QComboBox(this); + + // Create the description label BEFORE connecting signals and adding items m_presetDesc = new QLabel(this); m_presetDesc->setWordWrap(true); m_presetDesc->setStyleSheet("color: palette(mid);"); settingsGrid->addWidget(m_presetDesc, 1, 1, 1, 2); + + // Now connect the signal and add items (signal may fire during addItem) + connect(m_presetCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &ReencodeDialog::onPresetChanged); + for (const auto &p : m_presets) + m_presetCombo->addItem(p.label); + settingsGrid->addWidget(m_presetCombo, 0, 1, 1, 2); settingsGrid->addWidget(new QLabel("Output folder:", this), 2, 0); m_outputDir = new QLineEdit(this); @@ -77,6 +81,7 @@ void ReencodeDialog::buildUi() root->addWidget(settingsGroup); + // track table m_table = new QTableWidget(0, COL_COUNT, this); m_table->setHorizontalHeaderLabels({"Artist", "Title", "Status", "Output"}); m_table->horizontalHeader()->setSectionResizeMode(ColArtist, QHeaderView::ResizeToContents); @@ -89,6 +94,7 @@ void ReencodeDialog::buildUi() m_table->setAlternatingRowColors(true); root->addWidget(m_table, 1); + // Progress row auto *progressLayout = new QHBoxLayout; m_statusLabel = new QLabel("Ready", this); m_statusLabel->setMinimumWidth(120); @@ -100,6 +106,7 @@ void ReencodeDialog::buildUi() progressLayout->addWidget(m_overallBar, 1); root->addLayout(progressLayout); + // Button row auto *btnLayout = new QHBoxLayout; btnLayout->addStretch(); m_startStopBtn = new QPushButton("Start Encoding", this); @@ -111,28 +118,28 @@ void ReencodeDialog::buildUi() btnLayout->addWidget(m_closeBtn); root->addLayout(btnLayout); - connect(m_presetCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, &ReencodeDialog::onPresetChanged); - connect(m_browseBtn, &QPushButton::clicked, this, &ReencodeDialog::onBrowseOutput); - connect(m_startStopBtn,&QPushButton::clicked, this, &ReencodeDialog::onStartStop); - connect(m_closeBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(m_browseBtn, &QPushButton::clicked, this, &ReencodeDialog::onBrowseOutput); + connect(m_startStopBtn, &QPushButton::clicked, this, &ReencodeDialog::onStartStop); + connect(m_closeBtn, &QPushButton::clicked, this, &QDialog::reject); } void ReencodeDialog::populateTrackTable() { - m_table->setRowCount(m_tracks.size()); + m_table->setRowCount(static_cast(m_tracks.size())); for (int i = 0; i < m_tracks.size(); ++i) { const auto &t = m_tracks[i]; m_table->setItem(i, ColArtist, new QTableWidgetItem(t.artist)); m_table->setItem(i, ColTitle, new QTableWidgetItem(t.displayTitle())); m_table->setItem(i, ColStatus, new QTableWidgetItem("Pending")); - m_table->setItem(i, ColOutput, new QTableWidgetItem("")); + m_table->setItem(i, ColOutput, new QTableWidgetItem(QString{})); } } + void ReencodeDialog::onPresetChanged(int index) { - if (index < 0 || index >= m_presets.size()) return; + if (index < 0 || index >= m_presets.size()) + return; m_presetDesc->setText(m_presets[index].description); } @@ -142,9 +149,10 @@ void ReencodeDialog::onBrowseOutput() if (startDir.isEmpty() && !m_tracks.isEmpty()) startDir = QFileInfo(m_tracks.first().filePath).absolutePath(); - QString dir = QFileDialog::getExistingDirectory( + const QString dir = QFileDialog::getExistingDirectory( this, "Select Output Folder", startDir, QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (!dir.isEmpty()) m_outputDir->setText(dir); } @@ -158,16 +166,25 @@ void ReencodeDialog::onStartStop() return; } - int presetIdx = m_presetCombo->currentIndex(); - if (presetIdx < 0) return; + if (m_tracks.isEmpty()) + return; + + const int presetIdx = m_presetCombo->currentIndex(); + if (presetIdx < 0 || presetIdx >= m_presets.size()) + return; + const engine::EncodePreset &preset = m_presets[presetIdx]; + const QString outDir = m_outputDir->text().trimmed(); - QString outDir = m_outputDir->text().trimmed(); - + // reset every row to pending + const auto pendingBrush = QApplication::palette().text(); for (int i = 0; i < m_tracks.size(); ++i) { - m_table->item(i, ColStatus)->setText("Pending"); - m_table->item(i, ColStatus)->setForeground(QApplication::palette().text()); - m_table->item(i, ColOutput)->setText(""); + if (auto *st = m_table->item(i, ColStatus)) { + st->setText("Pending"); + st->setForeground(pendingBrush); + } + if (auto *op = m_table->item(i, ColOutput)) + op->setText(QString{}); } QList jobs; @@ -185,7 +202,7 @@ void ReencodeDialog::onStartStop() m_reencoder->setJobs(jobs); m_doneCount = 0; m_totalCount = jobs.size(); - m_overallBar->setRange(0, m_totalCount); + m_overallBar->setRange(0, static_cast(m_totalCount)); m_overallBar->setValue(0); setRunning(true); @@ -194,14 +211,18 @@ void ReencodeDialog::onStartStop() void ReencodeDialog::onTrackStarted(int index, const QString &title) { - m_statusLabel->setText(QString("Encoding %1/%2") - .arg(index + 1).arg(m_totalCount)); + m_statusLabel->setText( + QString("Encoding %1/%2 — %3") + .arg(index + 1) + .arg(m_totalCount) + .arg(title)); - if (QTableWidgetItem *it = m_table->item(index, ColStatus)) { - it->setText("Encoding..."); - } - m_table->scrollToItem(m_table->item(index, ColTitle)); - Q_UNUSED(title) + if (auto *st = m_table->item(index, ColStatus)) + st->setText("Encoding…"); + + // returns nullptr, gurad this shit + if (auto *cell = m_table->item(index, ColTitle)) + m_table->scrollToItem(cell); } void ReencodeDialog::onTrackFinished(int index, bool ok, @@ -211,15 +232,17 @@ void ReencodeDialog::onTrackFinished(int index, bool ok, ++m_doneCount; updateOverallProgress(); - if (QTableWidgetItem *st = m_table->item(index, ColStatus)) { + if (auto *st = m_table->item(index, ColStatus)) { if (ok) { st->setText("Done"); } else { st->setText(QString("Failed: %1").arg(err)); + st->setForeground(Qt::red); } } + if (ok) { - if (QTableWidgetItem *op = m_table->item(index, ColOutput)) + if (auto *op = m_table->item(index, ColOutput)) op->setText(QFileInfo(outPath).fileName()); } } @@ -227,35 +250,39 @@ void ReencodeDialog::onTrackFinished(int index, bool ok, void ReencodeDialog::onAllFinished(int succeeded, int failed) { setRunning(false); + m_doneCount = m_totalCount; + updateOverallProgress(); m_statusLabel->setText( - QString("Done - %1 succeeded, %2 failed").arg(succeeded).arg(failed)); - m_overallBar->setValue(m_totalCount); + QString("Done — %1 succeeded, %2 failed").arg(succeeded).arg(failed)); if (failed == 0) { QMessageBox::information(this, "Reencode Complete", QString("All %1 tracks encoded successfully.").arg(succeeded)); } else { QMessageBox::warning(this, "Reencode Complete", - QString("%1 tracks succeeded, %2 failed.\n" + QString("%1 track(s) succeeded, %2 failed.\n" "Check the Status column for details.") - .arg(succeeded).arg(failed)); + .arg(succeeded).arg(failed)); } } - void ReencodeDialog::setRunning(bool running) { m_presetCombo->setEnabled(!running); m_outputDir->setEnabled(!running); m_browseBtn->setEnabled(!running); + m_closeBtn->setEnabled(!running); m_startStopBtn->setText(running ? "Stop" : "Start Encoding"); m_startStopBtn->setEnabled(true); - m_closeBtn->setEnabled(!running); + + if (running) + m_statusLabel->setText("Starting…"); } void ReencodeDialog::updateOverallProgress() { - m_overallBar->setValue(m_doneCount); - m_overallBar->setFormat( - QString("%1 / %2").arg(m_doneCount).arg(m_totalCount)); + const int done = static_cast(m_doneCount); + const int total = static_cast(m_totalCount); + m_overallBar->setValue(done); + m_overallBar->setFormat(QString("%1 / %2").arg(done).arg(total)); } \ No newline at end of file diff --git a/src/gui/ReencodeDialog.h b/src/gui/ReencodeDialog.h index 8701c18..1120c44 100644 --- a/src/gui/ReencodeDialog.h +++ b/src/gui/ReencodeDialog.h @@ -1,6 +1,8 @@ #pragma once + #include #include + #include "engine/Track.h" #include "engine/encoder/ReencodeJob.h" @@ -12,20 +14,23 @@ class QLabel; class QProgressBar; namespace engine { class AlbumReencoder; } -class ReencodeDialog : public QDialog { + +class ReencodeDialog : public QDialog +{ Q_OBJECT + public: explicit ReencodeDialog(const QList &tracks, QWidget *parent = nullptr); - ~ReencodeDialog(); + ~ReencodeDialog() override; private slots: void onPresetChanged(int index); void onBrowseOutput(); void onStartStop(); void onTrackStarted (int index, const QString &title); - void onTrackFinished(int index, bool ok, const QString &outPath, - const QString &err); + void onTrackFinished(int index, bool ok, + const QString &outPath, const QString &err); void onAllFinished (int succeeded, int failed); private: @@ -34,21 +39,21 @@ private: void setRunning(bool running); void updateOverallProgress(); - QList m_tracks; - QList m_presets; + QList m_tracks; + QList m_presets; - QComboBox *m_presetCombo { nullptr }; - QLabel *m_presetDesc { nullptr }; - QLineEdit *m_outputDir { nullptr }; - QPushButton *m_browseBtn { nullptr }; - QTableWidget *m_table { nullptr }; - QProgressBar *m_overallBar { nullptr }; - QLabel *m_statusLabel { nullptr }; - QPushButton *m_startStopBtn { nullptr }; - QPushButton *m_closeBtn { nullptr }; + QComboBox *m_presetCombo { nullptr }; + QLabel *m_presetDesc { nullptr }; + QLineEdit *m_outputDir { nullptr }; + QPushButton *m_browseBtn { nullptr }; + QTableWidget *m_table { nullptr }; + QProgressBar *m_overallBar { nullptr }; + QLabel *m_statusLabel { nullptr }; + QPushButton *m_startStopBtn { nullptr }; + QPushButton *m_closeBtn { nullptr }; engine::AlbumReencoder *m_reencoder { nullptr }; - int m_doneCount { 0 }; - int m_totalCount { 0 }; + qsizetype m_doneCount { 0 }; + qsizetype m_totalCount { 0 }; }; \ No newline at end of file diff --git a/src/gui/SpectrogramWidget.cpp b/src/gui/SpectrogramWidget.cpp index 75b4cf4..10b13ee 100644 --- a/src/gui/SpectrogramWidget.cpp +++ b/src/gui/SpectrogramWidget.cpp @@ -1,5 +1,7 @@ +// SpectrogramWidget.cpp #include "SpectrogramWidget.h" #include "GradientDialog.h" + #include #include #include @@ -11,6 +13,10 @@ #include #include +// --------------------------------------------------------------------------- +// Construction / destruction +// --------------------------------------------------------------------------- + SpectrogramWidget::SpectrogramWidget(QWidget *parent) : QOpenGLWidget(parent) { @@ -28,6 +34,8 @@ SpectrogramWidget::SpectrogramWidget(QWidget *parent) loadBarColors(); + // Qt won't invoke paintGL until after initializeGL completes, + // so starting the timer here is safe even before the context exists. auto *timer = new QTimer(this); connect(timer, &QTimer::timeout, this, QOverload<>::of(&SpectrogramWidget::update)); @@ -38,17 +46,27 @@ SpectrogramWidget::SpectrogramWidget(QWidget *parent) SpectrogramWidget::~SpectrogramWidget() { + // FIX #1: if GL context creation failed (e.g. WSL / no GPU), + // initializeOpenGLFunctions() was never called, so glDelete* function + // pointers are null. Guard on m_glReady to avoid calling through them. + if (!m_glReady) + return; + makeCurrent(); glDeleteTextures(1, &m_heatmapTex); glDeleteVertexArrays(1, &m_vaoBars); doneCurrent(); } +// --------------------------------------------------------------------------- +// Public slots +// --------------------------------------------------------------------------- + void SpectrogramWidget::receiveFft(const QVector &mag) { QMutexLocker lk(&m_latestMtx); - m_latestMag = mag; - m_newMagAvailable = true; + m_latestMag = mag; + m_newMagAvailable = true; } void SpectrogramWidget::reset() @@ -58,23 +76,47 @@ void SpectrogramWidget::reset() m_latestMag.clear(); m_newMagAvailable = false; } + // Everything below is main-thread only — no additional lock needed. m_currentMag.fill(0.f); - m_barSmoothed.fill(0.f); - m_barPeak.fill(0.f); + m_barSmoothed .fill(0.f); + m_barPeak .fill(0.f); m_peakHoldTimer.fill(0.f); - m_dirty = false; + m_barHeights .fill(0.f); + m_barPeaksVec .fill(0.f); update(); } +// --------------------------------------------------------------------------- +// GL lifecycle +// --------------------------------------------------------------------------- + void SpectrogramWidget::initializeGL() { - initializeOpenGLFunctions(); + // FIX #1 (cont): initializeOpenGLFunctions resolves all GL 3.3 function + // pointers. If it returns false (no suitable context — common in WSL + // without a real GPU), leave m_glReady false and bail out. + // Every subsequent GL call site checks m_glReady first. + if (!initializeOpenGLFunctions()) { + qWarning() << "[Spectrogram] OpenGL 3.3 Core not available — " + "rendering disabled (WSL/no GPU?)"; + return; + } + initShaders(); - updateHeatmapTexture(); + + // FIX #5: only proceed if shaders actually linked. + // initShaders() already printed a warning on failure. + if (!m_barsProg.isLinked()) { + qWarning() << "[Spectrogram] Shader program failed — rendering disabled"; + return; + } glGenVertexArrays(1, &m_vaoBars); glDisable(GL_BLEND); + buildHeatmapTexture(); + + // Pre-compute the logarithmic frequency-to-bin mapping. const int bins = engine::SpectrumAnalyzer::BIN_COUNT; const double logMin = std::log10(20.0); const double logMax = std::log10(22050.0); @@ -83,30 +125,100 @@ void SpectrogramWidget::initializeGL() for (int b = 0; b < BAR_COUNT; ++b) { double f1 = std::pow(10.0, logMin + range * b / BAR_COUNT); double f2 = std::pow(10.0, logMin + range * (b + 1) / BAR_COUNT); - m_binMap[b].lo = std::clamp(int(f1 / 22050.0 * bins), 0, bins - 1); - m_binMap[b].hi = std::clamp(int(f2 / 22050.0 * bins), m_binMap[b].lo, bins - 1); + int lo = std::clamp(int(f1 / 22050.0 * bins), 0, bins - 1); + int hi = std::clamp(int(f2 / 22050.0 * bins), lo, bins - 1); + m_binMap[b] = { lo, hi }; } + + m_glReady = true; // all setup succeeded } +void SpectrogramWidget::resizeGL(int w, int h) +{ + // Viewport is managed by QOpenGLWidget automatically. + // Override present so Qt's virtual dispatch is explicit and future + // per-frame uniform updates (e.g. aspect ratio) have a home. + Q_UNUSED(w) Q_UNUSED(h) +} + +void SpectrogramWidget::paintGL() +{ + // FIX #3: GL functions are unresolved if context creation failed. + // Without this guard, glClear → null pointer → crash. + if (!m_glReady) + return; + + const float dt = static_cast(m_frameTimer.restart()) / 1000.f; + + // Swap in any new FFT data from the audio thread. + { + QMutexLocker lk(&m_latestMtx); + if (m_newMagAvailable) { + m_currentMag = std::move(m_latestMag); + m_latestMag = {}; + m_newMagAvailable = false; + } + } + + // Always run computeBars even with no new data — peaks decay and + // smoothing converges continuously via the dt parameter. + computeBars(dt); + + const QColor bg = palette().color(QPalette::Window); + glClearColor(bg.redF(), bg.greenF(), bg.blueF(), 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + const float barWidth = 2.0f / BAR_COUNT; + const float gap = barWidth * 0.2f; + const float drawWidth = barWidth - gap; + + m_barsProg.bind(); + glUniform1fv(m_uBarHeights, BAR_COUNT, m_barHeights .data()); + glUniform1fv(m_uBarPeaks, BAR_COUNT, m_barPeaksVec.data()); + glUniform1i (m_uBarCount, BAR_COUNT); + glUniform1f (m_uBarWidth, drawWidth); + glUniform1f (m_uGap, gap); + glUniform1f (m_uBottomY, -1.0f); + glUniform1f (m_uTopClip, 1.0f); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_1D, m_heatmapTex); + glUniform1i(m_uHeatmap, 0); + glBindVertexArray(m_vaoBars); + glDrawArraysInstanced(GL_TRIANGLES, 0, 6, BAR_COUNT); + glBindVertexArray(0); + m_barsProg.release(); +} + +// --------------------------------------------------------------------------- +// Private: GL helpers +// --------------------------------------------------------------------------- + void SpectrogramWidget::initShaders() { auto load = [](const QString &path) -> QByteArray { QFile f(path); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { - qWarning() << "Cannot open shader:" << path; + qWarning() << "[Spectrogram] Cannot open shader:" << path; return {}; } return f.readAll(); }; - if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Vertex, - load(":/shaders/bars.vert"))) - qWarning() << "Bars vert error:" << m_barsProg.log(); - if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Fragment, - load(":/shaders/bars.frag"))) - qWarning() << "Bars frag error:" << m_barsProg.log(); - if (!m_barsProg.link()) - qWarning() << "Bars link error:" << m_barsProg.log(); + const QByteArray vertSrc = load(":/shaders/bars.vert"); + const QByteArray fragSrc = load(":/shaders/bars.frag"); + + // FIX #5: bail before trying to compile empty source + if (vertSrc.isEmpty() || fragSrc.isEmpty()) + return; + + if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Vertex, vertSrc)) + qWarning() << "[Spectrogram] Vert error:" << m_barsProg.log(); + if (!m_barsProg.addShaderFromSourceCode(QOpenGLShader::Fragment, fragSrc)) + qWarning() << "[Spectrogram] Frag error:" << m_barsProg.log(); + if (!m_barsProg.link()) { + qWarning() << "[Spectrogram] Link error:" << m_barsProg.log(); + return; + } m_uBarHeights = m_barsProg.uniformLocation("barHeights"); m_uBarPeaks = m_barsProg.uniformLocation("barPeaks"); @@ -118,23 +230,27 @@ void SpectrogramWidget::initShaders() m_uHeatmap = m_barsProg.uniformLocation("heatmap"); } -void SpectrogramWidget::updateHeatmapTexture() +void SpectrogramWidget::buildHeatmapTexture() { if (m_stops.size() < 2) m_stops = { QColor(0, 0, 0), QColor(255, 255, 255) }; const int nSegs = m_stops.size() - 1; - auto lerp = [](int x, int y, float f) { + auto lerpCh = [](int x, int y, float f) { return std::clamp(int(x + (y - x) * f), 0, 255); }; + for (int i = 0; i < 256; ++i) { - float t = float(i) / 255.f * nSegs; - int seg = std::min(int(t), nSegs - 1); - float f = t - seg; - QColor a = m_stops[seg], b = m_stops[seg + 1]; - m_heatmap[i] = qRgb(lerp(a.red(), b.red(), f), - lerp(a.green(), b.green(), f), - lerp(a.blue(), b.blue(), f)); + float t = float(i) / 255.f * float(nSegs); + int seg = std::min(int(t), nSegs - 1); + float f = t - float(seg); + // FIX #8: renamed locals to ca/cb to avoid shadowing the + // outer QColor variable `b` from the original code + const QColor &ca = m_stops[seg]; + const QColor &cb = m_stops[seg + 1]; + m_heatmap[i] = qRgb(lerpCh(ca.red(), cb.red(), f), + lerpCh(ca.green(), cb.green(), f), + lerpCh(ca.blue(), cb.blue(), f)); } std::array td; @@ -157,30 +273,42 @@ void SpectrogramWidget::updateHeatmapTexture() glTexParameteri(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); } +// --------------------------------------------------------------------------- +// Private: gradient persistence +// --------------------------------------------------------------------------- + void SpectrogramWidget::setStops(const QVector &stops) { - if (stops.size() < 2) return; + if (stops.size() < 2) + return; m_stops = stops; - makeCurrent(); - updateHeatmapTexture(); - doneCurrent(); + + if (m_glReady) { + // FIX #2: makeCurrent() can return false (no valid context). + // Only call GL functions if it succeeded. + makeCurrent(); + buildHeatmapTexture(); + doneCurrent(); + } + update(); saveBarColors(); } void SpectrogramWidget::loadBarColors() { - QString path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) - + "/.subwave/barcolors.json"; + const QString path = + QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + + "/.subwave/barcolors.json"; QFile f(path); if (!f.open(QIODevice::ReadOnly)) return; - QJsonDocument doc = QJsonDocument::fromJson(f.readAll()); + const QJsonDocument doc = QJsonDocument::fromJson(f.readAll()); if (!doc.isArray()) return; QVector loaded; - for (const auto &v : doc.array()) { - QJsonObject obj = v.toObject(); + for (const QJsonValue &v : doc.array()) { + const QJsonObject obj = v.toObject(); loaded.append(QColor(obj["r"].toInt(), obj["g"].toInt(), obj["b"].toInt())); } if (loaded.size() >= 2) @@ -189,10 +317,12 @@ void SpectrogramWidget::loadBarColors() void SpectrogramWidget::saveBarColors() { - QString dir = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) - + "/.subwave"; - QDir().mkpath(dir); - QFile f(dir + "/barcolors.json"); + const QString dirPath = + QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + + "/.subwave"; + QDir().mkpath(dirPath); + + QFile f(dirPath + "/barcolors.json"); if (!f.open(QIODevice::WriteOnly)) return; QJsonArray arr; @@ -206,21 +336,16 @@ void SpectrogramWidget::saveBarColors() f.write(QJsonDocument(arr).toJson()); } - -void SpectrogramWidget::mousePressEvent(QMouseEvent *e) -{ - if (e->button() == Qt::LeftButton) { - GradientDialog dlg(m_stops, this); - if (dlg.exec() == QDialog::Accepted) - setStops(dlg.stops()); - } - QOpenGLWidget::mousePressEvent(e); -} - +// --------------------------------------------------------------------------- +// Private: bar computation +// --------------------------------------------------------------------------- void SpectrogramWidget::computeBars(float dt) { + // FIX #4: fftLen could be 0 if m_currentMag was somehow cleared; + // fftLen - 1 would wrap to INT_MAX as unsigned, or be -1 as signed. const int fftLen = m_currentMag.size(); + if (fftLen == 0) return; for (int b = 0; b < BAR_COUNT; ++b) { const int lo = m_binMap[b].lo; @@ -228,14 +353,16 @@ void SpectrogramWidget::computeBars(float dt) float sum = 0.f; int cnt = 0; - for (int k = lo; k <= hi; ++k) { sum += m_currentMag[k]; ++cnt; } - float raw = cnt > 0 ? sum / cnt : 0.f; + for (int k = lo; k <= hi; ++k) { + sum += m_currentMag[k]; + ++cnt; + } + const float raw = cnt > 0 ? sum / float(cnt) : 0.f; + const float db = raw > 0.f ? 20.f * std::log10(raw) : DB_MIN; + const float bar = std::clamp((db - DB_MIN) / (DB_MAX - DB_MIN), 0.f, 1.f); - float db = raw > 0.f ? 20.f * std::log10(raw) : DB_MIN; - float bar = std::clamp((db - DB_MIN) / (DB_MAX - DB_MIN), 0.f, 1.f); - - const float coeff = bar > m_barSmoothed[b] ? 0.50f : 0.18f; - m_barSmoothed[b] += (bar - m_barSmoothed[b]) * coeff; + const float coeff = bar > m_barSmoothed[b] ? 0.50f : 0.18f; + m_barSmoothed[b] += (bar - m_barSmoothed[b]) * coeff; if (m_barSmoothed[b] >= m_barPeak[b]) { m_barPeak[b] = m_barSmoothed[b]; @@ -251,46 +378,16 @@ void SpectrogramWidget::computeBars(float dt) } } +// --------------------------------------------------------------------------- +// Mouse +// --------------------------------------------------------------------------- -void SpectrogramWidget::paintGL() +void SpectrogramWidget::mousePressEvent(QMouseEvent *e) { - const float dt = static_cast(m_frameTimer.restart()) / 1000.f; - - { - QMutexLocker lk(&m_latestMtx); - if (m_newMagAvailable) { - m_currentMag = std::move(m_latestMag); - m_latestMag = {}; - m_newMagAvailable = false; - m_dirty = true; - } + if (e->button() == Qt::LeftButton) { + GradientDialog dlg(m_stops, this); + if (dlg.exec() == QDialog::Accepted) + setStops(dlg.stops()); } - - computeBars(dt); - - QColor bg = palette().color(QPalette::Window); - glClearColor(bg.redF(), bg.greenF(), bg.blueF(), 1.0f); - glClear(GL_COLOR_BUFFER_BIT); - - if (!m_barsProg.isLinked()) return; - - const float barWidth = 2.0f / BAR_COUNT; - const float gap = barWidth * 0.2f; - const float drawWidth = barWidth - gap; - - m_barsProg.bind(); - glUniform1fv(m_uBarHeights, BAR_COUNT, m_barHeights .data()); - glUniform1fv(m_uBarPeaks, BAR_COUNT, m_barPeaksVec.data()); - glUniform1i (m_uBarCount, BAR_COUNT); - glUniform1f (m_uBarWidth, drawWidth); - glUniform1f (m_uGap, gap); - glUniform1f (m_uBottomY, -1.0f); - glUniform1f (m_uTopClip, 1.0f); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_1D, m_heatmapTex); - glUniform1i(m_uHeatmap, 0); - glBindVertexArray(m_vaoBars); - glDrawArraysInstanced(GL_TRIANGLES, 0, 6, BAR_COUNT); - glBindVertexArray(0); - m_barsProg.release(); + QOpenGLWidget::mousePressEvent(e); } \ No newline at end of file diff --git a/src/gui/SpectrogramWidget.h b/src/gui/SpectrogramWidget.h index b0d76ac..04a722c 100644 --- a/src/gui/SpectrogramWidget.h +++ b/src/gui/SpectrogramWidget.h @@ -1,25 +1,25 @@ #pragma once + #include #include #include #include #include #include -#include #include #include #include "SpectrumAnalyzer.h" -class SpectrogramWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core +class SpectrogramWidget : public QOpenGLWidget, + protected QOpenGLFunctions_3_3_Core { Q_OBJECT - static constexpr int BAR_COUNT = 96; - static constexpr float DB_MIN = -80.f; - static constexpr float DB_MAX = 0.f; - - static constexpr float PEAK_HOLD_SEC = 0.4f; - static constexpr float PEAK_DECAY_SEC = 0.5f; + static constexpr int BAR_COUNT = 96; + static constexpr float DB_MIN = -80.f; + static constexpr float DB_MAX = 0.f; + static constexpr float PEAK_HOLD_SEC = 0.4f; + static constexpr float PEAK_DECAY_SEC = 0.5f; public: explicit SpectrogramWidget(QWidget *parent = nullptr); @@ -30,60 +30,57 @@ public slots: void reset(); protected: - void initializeGL() override; - void paintGL() override; + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; void mousePressEvent(QMouseEvent *e) override; private: - // GL setup void initShaders(); - void updateHeatmapTexture(); + void buildHeatmapTexture(); - // Data void setStops(const QVector &stops); void loadBarColors(); void saveBarColors(); + void computeBars(float dt); QMutex m_latestMtx; - QVector m_latestMag; // written by audio thread - bool m_newMagAvailable{false}; + QVector m_latestMag; + bool m_newMagAvailable { false }; QVector m_currentMag = QVector(engine::SpectrumAnalyzer::BIN_COUNT, 0.f); - // --- bar state --- - std::array m_barSmoothed{}; - std::array m_barPeak{}; - std::array m_peakHoldTimer{}; // seconds remaining in hold - - // Uploaded to GPU each frame - std::array m_barHeights{}; - std::array m_barPeaksVec{}; + std::array m_barSmoothed {}; + std::array m_barPeak {}; + std::array m_peakHoldTimer {}; + std::array m_barHeights {}; + std::array m_barPeaksVec {}; struct BinRange { int lo, hi; }; - std::array m_binMap{}; + std::array m_binMap {}; - QVector m_stops = { QColor("#078D70"), - QColor("#26CEAA"), - QColor("#98E8C1"), - QColor("#FFFFFF") }; - - std::array m_heatmap{}; + QVector m_stops = { + QColor("#078D70"), QColor("#26CEAA"), + QColor("#98E8C1"), QColor("#FFFFFF") + }; + std::array m_heatmap {}; QElapsedTimer m_frameTimer; - bool m_dirty{false}; // true when new data arrived since last paint + + bool m_glReady { false }; QOpenGLShaderProgram m_barsProg; - GLuint m_heatmapTex = 0; - GLuint m_vaoBars = 0; + GLuint m_heatmapTex { 0 }; + GLuint m_vaoBars { 0 }; - int m_uBarHeights = -1; - int m_uBarPeaks = -1; - int m_uBarCount = -1; - int m_uBarWidth = -1; - int m_uGap = -1; - int m_uBottomY = -1; - int m_uTopClip = -1; - int m_uHeatmap = -1; + int m_uBarHeights { -1 }; + int m_uBarPeaks { -1 }; + int m_uBarCount { -1 }; + int m_uBarWidth { -1 }; + int m_uGap { -1 }; + int m_uBottomY { -1 }; + int m_uTopClip { -1 }; + int m_uHeatmap { -1 }; }; \ No newline at end of file