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:
+15
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 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<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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user