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.
This commit is contained in:
2026-07-01 09:56:37 +02:00
parent 51421ffc2b
commit 1a00e98661
19 changed files with 984 additions and 1084 deletions
+15 -1
View File
@@ -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)
+4
View File
@@ -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
-477
View File
@@ -1,477 +0,0 @@
#include "AlbumReencoder.h"
#include <QDebug>
#include <QDir>
#include <QFileInfo>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/opt.h>
#include <libavutil/channel_layout.h>
#include <libswresample/swresample.h>
}
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<ReencodeJob> &jobs)
{
QMutexLocker lk(&m_mtx);
m_jobs = jobs;
}
QList<ReencodeJob> 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<int64_t>(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<std::vector<uint8_t>> stageBufs(numPlanes);
auto flushStage = [&](bool drain) -> bool {
while (true) {
int avail = static_cast<int>(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<uint8_t *> dstPtrs(numPlanes);
for (int pl = 0; pl < numPlanes; ++pl) {
int prevSz = static_cast<int>(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<const uint8_t **>(decFrame.p->data),
decFrame.p->nb_samples);
for (int pl = 0; pl < numPlanes; ++pl) {
int keepSz = static_cast<int>(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<uint8_t *> dstPtrs(numPlanes);
for (int pl = 0; pl < numPlanes; ++pl) {
int prevSz = static_cast<int>(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<const uint8_t **>(decFrame.p->data), decFrame.p->nb_samples);
for (int pl = 0; pl < numPlanes; ++pl) {
int keepSz = static_cast<int>(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<uint8_t *> dstPtrs(numPlanes);
for (int pl = 0; pl < numPlanes; ++pl) {
int prevSz = static_cast<int>(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<int>(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
-44
View File
@@ -1,44 +0,0 @@
#pragma once
#include <QObject>
#include <QThread>
#include <QMutex>
#include <QList>
#include <atomic>
#include "ReencodeJob.h"
namespace engine {
class AlbumReencoder : public QObject {
Q_OBJECT
public:
explicit AlbumReencoder(QObject *parent = nullptr);
~AlbumReencoder();
void setJobs(const QList<ReencodeJob> &jobs);
void start();
void requestStop();
bool isRunning() const;
QList<ReencodeJob> 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<ReencodeJob> m_jobs;
mutable QMutex m_mtx;
QThread *m_thread { nullptr };
std::atomic<bool> m_stopReq { false };
};
} // namespace engine
+39 -19
View File
@@ -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);
+2 -4
View File
@@ -1,20 +1,17 @@
#pragma once
#include "ReencodeJob.h"
#include <QList>
#include <QMutex>
#include <QObject>
#include <QThread>
#include <atomic>
#include <climits>
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();
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <QString>
#include <algorithm>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/channel_layout.h>
#include <libavutil/samplefmt.h>
}
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: 6510 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
+84
View File
@@ -0,0 +1,84 @@
#pragma once
#include "FfmpegGuards.h"
#include <QString>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
}
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<int>(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
+199
View File
@@ -0,0 +1,199 @@
#pragma once
#include "FfmpegGuards.h"
#include "CodecUtils.h"
#include "SampleRateUtils.h"
#include "ReencodeJob.h"
#include <QString>
#include <functional>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/channel_layout.h>
#include <libswresample/swresample.h>
}
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<bool(AVFrame *)>;
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
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "ReencodeJob.h"
#include <QDir>
#include <QFileInfo>
#include <QString>
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
+11 -1
View File
@@ -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
} // namespace engine
+67 -37
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cstring>
#include <QtGlobal>
extern "C" {
#include <libavutil/avutil.h>
@@ -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<uint8_t *> 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<int>(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<int>(
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<int>(m_bufs[pl].size()))
return false;
m_bufs[pl].erase(
m_bufs[pl].begin(),
m_bufs[pl].begin() + bytes
);
}
}
return true;
}
+64 -249
View File
@@ -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 <QDir>
#include <QFileInfo>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libavutil/avutil.h>
#include <libavutil/opt.h>
#include <libavutil/channel_layout.h>
#include <libswresample/swresample.h>
}
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: 6510 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<bool> &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<int>(
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<const uint8_t **>(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<const uint8_t **>(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<const uint8_t **>(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;
}
+64 -46
View File
@@ -23,6 +23,8 @@
#include <QKeySequence>
#include <QDir>
#include <QApplication>
#include <QCheckBox>
#include <QMetaObject>
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<engine::Track> 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);
}
+11 -8
View File
@@ -1,4 +1,6 @@
// MainWindow.h
#pragma once
#include <QMainWindow>
#include <QTimer>
@@ -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 };
};
+70 -43
View File
@@ -1,5 +1,5 @@
#include "ReencodeDialog.h"
#include "engine/AlbumReencoder.h"
#include "engine/encoder/AlbumReencoder.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -15,10 +15,9 @@
#include <QFileDialog>
#include <QMessageBox>
#include <QApplication>
#include <QDir>
#include <QFileInfo>
enum Col { ColArtist=0, ColTitle, ColStatus, ColOutput, COL_COUNT };
enum Col { ColArtist = 0, ColTitle, ColStatus, ColOutput, COL_COUNT };
ReencodeDialog::ReencodeDialog(const QList<engine::Track> &tracks, QWidget *parent)
: QDialog(parent)
@@ -30,7 +29,6 @@ ReencodeDialog::ReencodeDialog(const QList<engine::Track> &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<int>::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<int>::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<int>(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<engine::ReencodeJob> 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<int>(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<int>(m_doneCount);
const int total = static_cast<int>(m_totalCount);
m_overallBar->setValue(done);
m_overallBar->setFormat(QString("%1 / %2").arg(done).arg(total));
}
+22 -17
View File
@@ -1,6 +1,8 @@
#pragma once
#include <QDialog>
#include <QList>
#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<engine::Track> &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<engine::Track> m_tracks;
QList<engine::EncodePreset> m_presets;
QList<engine::Track> m_tracks;
QList<engine::EncodePreset> 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 };
};
+194 -97
View File
@@ -1,5 +1,7 @@
// SpectrogramWidget.cpp
#include "SpectrogramWidget.h"
#include "GradientDialog.h"
#include <QTimer>
#include <QSurfaceFormat>
#include <QFile>
@@ -11,6 +13,10 @@
#include <QStandardPaths>
#include <cmath>
// ---------------------------------------------------------------------------
// 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<float> &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<float>(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<GLubyte, 256 * 3> 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<QColor> &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<QColor> 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<float>(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);
}
+38 -41
View File
@@ -1,25 +1,25 @@
#pragma once
#include <QOpenGLWidget>
#include <QOpenGLFunctions_3_3_Core>
#include <QOpenGLShaderProgram>
#include <QElapsedTimer>
#include <QMutex>
#include <array>
#include <atomic>
#include <QColor>
#include <QVector>
#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<QColor> &stops);
void loadBarColors();
void saveBarColors();
void computeBars(float dt);
QMutex m_latestMtx;
QVector<float> m_latestMag; // written by audio thread
bool m_newMagAvailable{false};
QVector<float> m_latestMag;
bool m_newMagAvailable { false };
QVector<float> m_currentMag =
QVector<float>(engine::SpectrumAnalyzer::BIN_COUNT, 0.f);
// --- bar state ---
std::array<float, BAR_COUNT> m_barSmoothed{};
std::array<float, BAR_COUNT> m_barPeak{};
std::array<float, BAR_COUNT> m_peakHoldTimer{}; // seconds remaining in hold
// Uploaded to GPU each frame
std::array<float, BAR_COUNT> m_barHeights{};
std::array<float, BAR_COUNT> m_barPeaksVec{};
std::array<float, BAR_COUNT> m_barSmoothed {};
std::array<float, BAR_COUNT> m_barPeak {};
std::array<float, BAR_COUNT> m_peakHoldTimer {};
std::array<float, BAR_COUNT> m_barHeights {};
std::array<float, BAR_COUNT> m_barPeaksVec {};
struct BinRange { int lo, hi; };
std::array<BinRange, BAR_COUNT> m_binMap{};
std::array<BinRange, BAR_COUNT> m_binMap {};
QVector<QColor> m_stops = { QColor("#078D70"),
QColor("#26CEAA"),
QColor("#98E8C1"),
QColor("#FFFFFF") };
std::array<QRgb, 256> m_heatmap{};
QVector<QColor> m_stops = {
QColor("#078D70"), QColor("#26CEAA"),
QColor("#98E8C1"), QColor("#FFFFFF")
};
std::array<QRgb, 256> 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 };
};