From 15092ffa8254034b337da4c654e938423aa6d99d Mon Sep 17 00:00:00 2001 From: 0000xFFFF Date: Tue, 23 Jun 2026 23:28:09 +0200 Subject: [PATCH 1/7] waveform plugin --- meson.build | 1 + meson_options.txt | 2 + src/meson.build | 6 + src/waveform-qt/Makefile | 13 + src/waveform-qt/meson.build | 13 + src/waveform-qt/waveform_qt.cc | 643 +++++++++++++++++++++++++++++++++ 6 files changed, 678 insertions(+) create mode 100644 src/waveform-qt/Makefile create mode 100644 src/waveform-qt/meson.build create mode 100644 src/waveform-qt/waveform_qt.cc diff --git a/meson.build b/meson.build index 8260aefa0..9d3380777 100644 --- a/meson.build +++ b/meson.build @@ -336,6 +336,7 @@ if conf.has('USE_QT') 'Stream Tuner (experimental)': get_variable('have_streamtuner', false), 'VU Meter': get_option('vumeter'), 'X11 Global Hotkeys': get_variable('have_qthotkey', false), + 'Waveform': true, }, section: 'Qt Support') endif diff --git a/meson_options.txt b/meson_options.txt index a3e5ba1d4..6d4e64d52 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -199,3 +199,5 @@ option('spectrum-analyzer', type: 'boolean', value: true, description: 'Whether the Spectrum Analyzer (2D) plugin is enabled') option('vumeter', type: 'boolean', value: true, description: 'Whether the VU Meter visualization plugin is enabled') +option('waveform', type: 'boolean', value: true, + description: 'Whether the Waveform visualization plugin is enabled') \ No newline at end of file diff --git a/src/meson.build b/src/meson.build index 34b48ced0..d40254b81 100644 --- a/src/meson.build +++ b/src/meson.build @@ -190,6 +190,10 @@ if conf.has('USE_QT') if get_option('vumeter') subdir('vumeter-qt') endif + + if get_option('waveform') + subdir('waveform-qt') + endif endif @@ -258,6 +262,8 @@ if conf.has('USE_GTK') if get_option('vumeter') subdir('vumeter') endif + + # TODO: add waveform for GTK endif diff --git a/src/waveform-qt/Makefile b/src/waveform-qt/Makefile new file mode 100644 index 000000000..415695956 --- /dev/null +++ b/src/waveform-qt/Makefile @@ -0,0 +1,13 @@ +PLUGIN = waveform-qt${PLUGIN_SUFFIX} + +SRCS = waveform_qt.cc waveform_qt_widget.cc + +include ../../buildsys.mk +include ../../extra.mk + +plugindir := ${plugindir}/${VISUALIZATION_PLUGIN_DIR} + +LD = ${CXX} +CFLAGS += ${PLUGIN_CFLAGS} +CPPFLAGS += ${PLUGIN_CPPFLAGS} -I../.. ${QT_CFLAGS} +LIBS += -lm ${QT_LIBS} diff --git a/src/waveform-qt/meson.build b/src/waveform-qt/meson.build new file mode 100644 index 000000000..9c315a940 --- /dev/null +++ b/src/waveform-qt/meson.build @@ -0,0 +1,13 @@ +avformat_dep = dependency('libavformat') +avcodec_dep = dependency('libavcodec') +avutil_dep = dependency('libavutil') +swresample_dep = dependency('libswresample') +threads_dep = dependency('threads') + +shared_module('waveform-qt', + 'waveform_qt.cc', + dependencies: [audacious_dep, qt_dep, avformat_dep, avcodec_dep, avutil_dep, swresample_dep, threads_dep], + name_prefix: '', + install: true, + install_dir: visualization_plugin_dir +) diff --git a/src/waveform-qt/waveform_qt.cc b/src/waveform-qt/waveform_qt.cc new file mode 100644 index 000000000..bf0b3d34b --- /dev/null +++ b/src/waveform-qt/waveform_qt.cc @@ -0,0 +1,643 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include +#include +#include /* must come before libaudcore/i18n.h to prevent macro-poisoning */ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +#define NUM_BUCKETS 2000 + +struct TrackPeaks +{ + int16_t * mins; + int16_t * maxs; + int32_t num_buckets; + int32_t global_peak; + double duration_sec; + + TrackPeaks() + : mins(nullptr), maxs(nullptr), num_buckets(0), global_peak(1), + duration_sec(0) + { + } + + ~TrackPeaks() + { + delete[] mins; + delete[] maxs; + } + + void resize(int32_t n) + { + delete[] mins; + delete[] maxs; + num_buckets = n; + mins = new int16_t[n](); + maxs = new int16_t[n](); + } +}; + +static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; +static TrackPeaks * g_peaks = nullptr; /* envelope currently displayed */ +static char * g_current_file = nullptr; /* file the envelope above belongs to */ + +static char * cache_path_for(const char * filename) +{ + /* simple FNV-1a hash of the path -> cache filename */ + uint64_t h = 1469598103934665603ULL; + for (int i = 0; filename[i] != '\0'; i++) + { + h ^= (unsigned char)filename[i]; + h *= 1099511628211ULL; + } + + char hex[17]; + snprintf(hex, sizeof hex, "%016llx", (unsigned long long)h); + + const char * user_dir = aud_get_path(AudPath::UserDir); + int dir_len = strlen(user_dir) + 16; + char * dir = new char[dir_len]; + snprintf(dir, dir_len, "%s/waveform_cache", user_dir); + + mkdir(dir, 0755); + + int path_len = strlen(dir) + 24; + char * path = new char[path_len]; + snprintf(path, path_len, "%s/%s.peaks", dir, hex); + + delete[] dir; + return path; +} + +static bool load_cache(const char * path, TrackPeaks * out) +{ + FILE * f = fopen(path, "rb"); + if (!f) + return false; + + int32_t n = 0; + bool ok = fread(&n, sizeof n, 1, f) == 1 && n == NUM_BUCKETS; + if (ok) + { + out->resize(n); + ok = fread(&out->global_peak, sizeof out->global_peak, 1, f) == 1 && + fread(&out->duration_sec, sizeof out->duration_sec, 1, f) == 1 && + fread(out->mins, sizeof(int16_t), n, f) == (size_t)n && + fread(out->maxs, sizeof(int16_t), n, f) == (size_t)n; + } + fclose(f); + return ok; +} + +static void save_cache(const char * path, const TrackPeaks * in) +{ + FILE * f = fopen(path, "wb"); + if (!f) + return; + int32_t n = in->num_buckets; + fwrite(&n, sizeof n, 1, f); + fwrite(&in->global_peak, sizeof in->global_peak, 1, f); + fwrite(&in->duration_sec, sizeof in->duration_sec, 1, f); + fwrite(in->mins, sizeof(int16_t), n, f); + fwrite(in->maxs, sizeof(int16_t), n, f); + fclose(f); +} + +struct PumpContext +{ + int16_t * samples; + size_t samples_count; + size_t samples_capacity; + SwrContext * swr; + int16_t * conv_buf; + int conv_buf_capacity; +}; + +static void pump_frame(AVFrame * fr, PumpContext * ctx) +{ + int max_out = swr_get_out_samples(ctx->swr, fr->nb_samples); + if (ctx->conv_buf_capacity < max_out) + { + delete[] ctx->conv_buf; + ctx->conv_buf_capacity = max_out; + ctx->conv_buf = new int16_t[max_out]; + } + uint8_t * planes[1] = {(uint8_t *)ctx->conv_buf}; + int got = swr_convert(ctx->swr, planes, max_out, (const uint8_t **)fr->data, + fr->nb_samples); + if (got > 0) + { + if (ctx->samples_count + got > ctx->samples_capacity) + { + ctx->samples_capacity = (ctx->samples_count + got) * 2; + int16_t * new_samples = new int16_t[ctx->samples_capacity]; + memcpy(new_samples, ctx->samples, + ctx->samples_count * sizeof(int16_t)); + delete[] ctx->samples; + ctx->samples = new_samples; + } + memcpy(ctx->samples + ctx->samples_count, ctx->conv_buf, + got * sizeof(int16_t)); + ctx->samples_count += got; + } +} + +static bool decode_peaks(const char * filename, TrackPeaks * out) +{ + AVFormatContext * fmt_ctx = nullptr; + if (avformat_open_input(&fmt_ctx, filename, nullptr, nullptr) < 0) + return false; + if (avformat_find_stream_info(fmt_ctx, nullptr) < 0) + { + avformat_close_input(&fmt_ctx); + return false; + } + + int audio_idx = -1; + for (unsigned i = 0; i < fmt_ctx->nb_streams; i++) + if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) + { + audio_idx = i; + break; + } + + if (audio_idx < 0) + { + avformat_close_input(&fmt_ctx); + return false; + } + + AVCodecParameters * params = fmt_ctx->streams[audio_idx]->codecpar; + const AVCodec * codec = avcodec_find_decoder(params->codec_id); + if (!codec) + { + avformat_close_input(&fmt_ctx); + return false; + } + + AVCodecContext * ctx = avcodec_alloc_context3(codec); + avcodec_parameters_to_context(ctx, params); + if (avcodec_open2(ctx, codec, nullptr) < 0) + { + avcodec_free_context(&ctx); + avformat_close_input(&fmt_ctx); + return false; + } + + AVChannelLayout out_layout; + av_channel_layout_default(&out_layout, 1); + + SwrContext * swr = nullptr; + if (swr_alloc_set_opts2(&swr, &out_layout, AV_SAMPLE_FMT_S16, + ctx->sample_rate, &ctx->ch_layout, ctx->sample_fmt, + ctx->sample_rate, 0, nullptr) < 0 || + !swr || swr_init(swr) < 0) + { + avcodec_free_context(&ctx); + avformat_close_input(&fmt_ctx); + return false; + } + + PumpContext pctx; + pctx.samples_capacity = (size_t)ctx->sample_rate * 60 * 4; + pctx.samples = new int16_t[pctx.samples_capacity]; + pctx.samples_count = 0; + pctx.swr = swr; + pctx.conv_buf_capacity = 1024; + pctx.conv_buf = new int16_t[pctx.conv_buf_capacity]; + + AVPacket * pkt = av_packet_alloc(); + AVFrame * frame = av_frame_alloc(); + + while (av_read_frame(fmt_ctx, pkt) >= 0) + { + if (pkt->stream_index == audio_idx && + avcodec_send_packet(ctx, pkt) == 0) + while (avcodec_receive_frame(ctx, frame) == 0) + pump_frame(frame, &pctx); + av_packet_unref(pkt); + } + avcodec_send_packet(ctx, nullptr); + while (avcodec_receive_frame(ctx, frame) == 0) + pump_frame(frame, &pctx); + + int drain; + do + { + uint8_t * planes[1] = {(uint8_t *)pctx.conv_buf}; + drain = swr_convert(swr, planes, pctx.conv_buf_capacity, nullptr, 0); + if (drain > 0) + { + if (pctx.samples_count + drain > pctx.samples_capacity) + { + pctx.samples_capacity = (pctx.samples_count + drain) * 2; + int16_t * new_samples = new int16_t[pctx.samples_capacity]; + memcpy(new_samples, pctx.samples, + pctx.samples_count * sizeof(int16_t)); + delete[] pctx.samples; + pctx.samples = new_samples; + } + memcpy(pctx.samples + pctx.samples_count, pctx.conv_buf, + drain * sizeof(int16_t)); + pctx.samples_count += drain; + } + } while (drain > 0); + + av_frame_free(&frame); + av_packet_free(&pkt); + swr_free(&swr); + avcodec_free_context(&ctx); + int sample_rate = fmt_ctx->streams[audio_idx]->codecpar->sample_rate; + avformat_close_input(&fmt_ctx); + + if (pctx.samples_count == 0) + { + delete[] pctx.samples; + delete[] pctx.conv_buf; + return false; + } + + out->resize(NUM_BUCKETS); + out->global_peak = 1; + out->duration_sec = (double)pctx.samples_count / sample_rate; + + for (int b = 0; b < NUM_BUCKETS; b++) + { + size_t start = (size_t)((double)b / NUM_BUCKETS * pctx.samples_count); + size_t end = + (size_t)((double)(b + 1) / NUM_BUCKETS * pctx.samples_count); + if (end > pctx.samples_count) + end = pctx.samples_count; + if (end <= start) + end = start + 1; + if (end > pctx.samples_count) + end = pctx.samples_count; + + int16_t mn = 0, mx = 0; + for (size_t i = start; i < end; i++) + { + if (pctx.samples[i] < mn) + mn = pctx.samples[i]; + if (pctx.samples[i] > mx) + mx = pctx.samples[i]; + } + out->mins[b] = mn; + out->maxs[b] = mx; + if (-mn > out->global_peak) + out->global_peak = -mn; + if (mx > out->global_peak) + out->global_peak = mx; + } + + delete[] pctx.samples; + delete[] pctx.conv_buf; + return true; +} + +class WaveformWidget; +static WaveformWidget * spect_widget = nullptr; +static void load_track_async(const char * filename); + +class WaveformWidget : public QWidget +{ +public: + WaveformWidget(QWidget * parent = nullptr); + ~WaveformWidget(); + + void apply_peaks(const char * filename, TrackPeaks * peaks); + +protected: + void resizeEvent(QResizeEvent *) override; + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent * event) override; + void mouseMoveEvent(QMouseEvent * event) override; + +private: + void paint_background(QPainter &); + void paint_waveform(QPainter &); + void paint_playhead(QPainter &); + void seek_to_x(int x); + + QTimer * m_timer; +}; + +WaveformWidget::WaveformWidget(QWidget * parent) : QWidget(parent) +{ + spect_widget = this; + + m_timer = new QTimer(this); + QObject::connect(m_timer, &QTimer::timeout, this, [this]() { update(); }); + m_timer->start(100); + + setMinimumHeight(80); + + if (aud_drct_get_ready()) + { + String fn = aud_drct_get_filename(); + if (fn) + load_track_async((const char *)fn); + } +} + +WaveformWidget::~WaveformWidget() { spect_widget = nullptr; } + +void WaveformWidget::apply_peaks(const char * filename, TrackPeaks * peaks) +{ + pthread_mutex_lock(&g_mutex); + delete[] g_current_file; + g_current_file = strdup(filename); + delete g_peaks; + g_peaks = peaks; + pthread_mutex_unlock(&g_mutex); + update(); +} + +void WaveformWidget::paint_background(QPainter & p) +{ + p.fillRect(0, 0, width(), height(), QColor(18, 18, 20)); +} + +void WaveformWidget::paint_waveform(QPainter & p) +{ + TrackPeaks local_peaks; + pthread_mutex_lock(&g_mutex); + if (g_peaks && g_peaks->num_buckets > 0) + { + local_peaks.resize(g_peaks->num_buckets); + local_peaks.global_peak = g_peaks->global_peak; + local_peaks.duration_sec = g_peaks->duration_sec; + memcpy(local_peaks.mins, g_peaks->mins, + g_peaks->num_buckets * sizeof(int16_t)); + memcpy(local_peaks.maxs, g_peaks->maxs, + g_peaks->num_buckets * sizeof(int16_t)); + } + pthread_mutex_unlock(&g_mutex); + + int w = width(), h = height(); + int center_y = h / 2; + + p.setPen(QColor(90, 90, 95)); + p.drawLine(0, center_y, w, center_y); + + if (local_peaks.num_buckets == 0) + return; + + double half = h / 2.0; + double scale = (half * 0.95) / (double)local_peaks.global_peak; + int n = local_peaks.num_buckets; + + p.setPen(Qt::NoPen); + p.setBrush(QColor(64, 200, 255)); + + for (int x = 0; x < w; x++) + { + int idx = x * n / w; + if (idx >= n) + idx = n - 1; + double top = center_y - local_peaks.maxs[idx] * scale; + double bot = center_y - local_peaks.mins[idx] * scale; + if (bot < top) + { + double tmp = top; + top = bot; + bot = tmp; + } + p.drawRect(QRectF(x, top, 1.0, bot - top + 1.0)); + } +} + +void WaveformWidget::paint_playhead(QPainter & p) +{ + if (!aud_drct_get_playing()) + return; + + int length = aud_drct_get_length(); + if (length <= 0) + return; + + int time = aud_drct_get_time(); + double frac = (double)time / length; + if (frac < 0) + frac = 0; + if (frac > 1) + frac = 1; + + double x = frac * width(); + + QPen pen(QColor(255, 40, 40)); + pen.setWidth(2); + p.setPen(pen); + p.drawLine(QPointF(x, 0), QPointF(x, height())); +} + +void WaveformWidget::resizeEvent(QResizeEvent *) { update(); } + +void WaveformWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + paint_background(p); + paint_waveform(p); + paint_playhead(p); +} + +void WaveformWidget::seek_to_x(int x) +{ + int length = aud_drct_get_length(); + if (length <= 0 || width() <= 0) + return; + + double frac = (double)x / width(); + if (frac < 0) + frac = 0; + if (frac > 1) + frac = 1; + + aud_drct_seek((int)(frac * length)); + update(); +} + +void WaveformWidget::mousePressEvent(QMouseEvent * event) +{ + if (event->button() == Qt::LeftButton) + seek_to_x(event->pos().x()); +} + +void WaveformWidget::mouseMoveEvent(QMouseEvent * event) +{ + if (event->buttons() & Qt::LeftButton) + seek_to_x(event->pos().x()); +} + +// async loader thread helper +static void * worker_thread_func(void * arg) +{ + char * filename = (char *)arg; + TrackPeaks * peaks = new TrackPeaks(); + char * cpath = cache_path_for(filename); + + bool ok = load_cache(cpath, peaks); + if (!ok) + { + StringBuf local = uri_to_filename(filename); + const char * decode_path = local ? (const char *)local : filename; + + ok = decode_peaks(decode_path, peaks); + if (ok) + save_cache(cpath, peaks); + } + delete[] cpath; + + if (!ok) + { + delete peaks; + free(filename); + return nullptr; + } + + /* dynamic copies passed along safely to the Qt event loop lambda */ + char * fn_copy = strdup(filename); + QMetaObject::invokeMethod( + qApp, + [fn_copy, peaks]() { + if (spect_widget) + spect_widget->apply_peaks(fn_copy, peaks); + else + { + pthread_mutex_lock(&g_mutex); + delete[] g_current_file; + g_current_file = strdup(fn_copy); + delete g_peaks; + g_peaks = peaks; + pthread_mutex_unlock(&g_mutex); + } + free(fn_copy); + }, + Qt::QueuedConnection); + + free(filename); + return nullptr; +} + +static void load_track_async(const char * filename) +{ + pthread_mutex_lock(&g_mutex); + if (g_current_file && strcmp(filename, g_current_file) == 0) + { + pthread_mutex_unlock(&g_mutex); + return; + } + pthread_mutex_unlock(&g_mutex); + + char * fn_arg = strdup(filename); + pthread_t thread; + if (pthread_create(&thread, nullptr, worker_thread_func, fn_arg) == 0) + { + pthread_detach(thread); + } + else + { + free(fn_arg); + } +} + +static void on_playback_ready(void *, void *) +{ + String fn = aud_drct_get_filename(); + if (fn) + load_track_async((const char *)fn); +} + +class QtWaveform : public VisPlugin +{ +public: + static constexpr PluginInfo info = {N_("Waveform"), PACKAGE, nullptr, + nullptr, PluginQtOnly}; + + constexpr QtWaveform() : VisPlugin(info, Visualizer::Freq) {} + + bool init() override; + void cleanup() override; + void * get_qt_widget() override; + void clear() override; + void render_freq(const float * freq) override; +}; + +EXPORT QtWaveform aud_plugin_instance; + +bool QtWaveform::init() +{ + hook_associate("playback ready", (HookFunction)on_playback_ready, nullptr); + return true; +} + +void QtWaveform::cleanup() +{ + hook_dissociate("playback ready", (HookFunction)on_playback_ready); + pthread_mutex_lock(&g_mutex); + delete[] g_current_file; + g_current_file = nullptr; + delete g_peaks; + g_peaks = nullptr; + pthread_mutex_unlock(&g_mutex); +} + +void QtWaveform::render_freq(const float * freq) {} + +void QtWaveform::clear() +{ + if (spect_widget) + spect_widget->update(); +} + +void * QtWaveform::get_qt_widget() +{ + if (spect_widget) + return spect_widget; + + spect_widget = new WaveformWidget(); + return spect_widget; +} \ No newline at end of file From b89ddd50cfd0d9026f9bb182e11fc55bcf0ae2d6 Mon Sep 17 00:00:00 2001 From: 0000xFFFF Date: Wed, 24 Jun 2026 12:43:30 +0200 Subject: [PATCH 2/7] support legacy and modern ffmpeg --- src/waveform-qt/waveform_qt.cc | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/waveform-qt/waveform_qt.cc b/src/waveform-qt/waveform_qt.cc index bf0b3d34b..c34672441 100644 --- a/src/waveform-qt/waveform_qt.cc +++ b/src/waveform-qt/waveform_qt.cc @@ -228,10 +228,13 @@ static bool decode_peaks(const char * filename, TrackPeaks * out) return false; } + SwrContext * swr = nullptr; + +#if LIBAVCODEC_VERSION_INT >= AV_VERSION_INT(59, 37, 100) + /* Modern FFmpeg (>= 5.1) Channel Layout API */ AVChannelLayout out_layout; av_channel_layout_default(&out_layout, 1); - SwrContext * swr = nullptr; if (swr_alloc_set_opts2(&swr, &out_layout, AV_SAMPLE_FMT_S16, ctx->sample_rate, &ctx->ch_layout, ctx->sample_fmt, ctx->sample_rate, 0, nullptr) < 0 || @@ -241,6 +244,27 @@ static bool decode_peaks(const char * filename, TrackPeaks * out) avformat_close_input(&fmt_ctx); return false; } +#else + /* Legacy FFmpeg (< 5.1) Channel Layout API */ + uint64_t out_layout = AV_CH_LAYOUT_MONO; + uint64_t in_layout = ctx->channel_layout; + + if (!in_layout) + in_layout = av_get_default_channel_layout(ctx->channels); + + swr = swr_alloc_set_opts(nullptr, out_layout, AV_SAMPLE_FMT_S16, + ctx->sample_rate, in_layout, ctx->sample_fmt, + ctx->sample_rate, 0, nullptr); + + if (!swr || swr_init(swr) < 0) + { + if (swr) + swr_free(&swr); + avcodec_free_context(&ctx); + avformat_close_input(&fmt_ctx); + return false; + } +#endif PumpContext pctx; pctx.samples_capacity = (size_t)ctx->sample_rate * 60 * 4; From 2ee30d70c662b4d13f8272088ae75d7f51772694 Mon Sep 17 00:00:00 2001 From: 0000xFFFF Date: Wed, 24 Jun 2026 12:44:59 +0200 Subject: [PATCH 3/7] use qt's mkdir --- src/waveform-qt/waveform_qt.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/waveform-qt/waveform_qt.cc b/src/waveform-qt/waveform_qt.cc index c34672441..f1195b3f3 100644 --- a/src/waveform-qt/waveform_qt.cc +++ b/src/waveform-qt/waveform_qt.cc @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -103,7 +104,7 @@ static char * cache_path_for(const char * filename) char * dir = new char[dir_len]; snprintf(dir, dir_len, "%s/waveform_cache", user_dir); - mkdir(dir, 0755); + QDir().mkpath(QString::fromUtf8(dir)); int path_len = strlen(dir) + 24; char * path = new char[path_len]; From 0d6325e0a4db5943e5bd9d131569e43e088f5a38 Mon Sep 17 00:00:00 2001 From: 0000xFFFF Date: Wed, 24 Jun 2026 22:32:58 +0200 Subject: [PATCH 4/7] color each band (low -> red, mid -> green, high -> blue) --- src/waveform-qt/waveform_qt.cc | 133 +++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 6 deletions(-) diff --git a/src/waveform-qt/waveform_qt.cc b/src/waveform-qt/waveform_qt.cc index f1195b3f3..d28aeaca2 100644 --- a/src/waveform-qt/waveform_qt.cc +++ b/src/waveform-qt/waveform_qt.cc @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -52,17 +53,23 @@ extern "C" { #define NUM_BUCKETS 2000 +/* per-bucket band intensities, 0-255, normalized independently per band so + * each color channel uses the full range (this is what gives quiet + * high-hats/cymbals visible color instead of bass just drowning them out) */ struct TrackPeaks { int16_t * mins; int16_t * maxs; + uint8_t * low; /* bass energy, -> red channel */ + uint8_t * mid; /* mid energy, -> green channel */ + uint8_t * high; /* treble energy -> blue channel */ int32_t num_buckets; int32_t global_peak; double duration_sec; TrackPeaks() - : mins(nullptr), maxs(nullptr), num_buckets(0), global_peak(1), - duration_sec(0) + : mins(nullptr), maxs(nullptr), low(nullptr), mid(nullptr), + high(nullptr), num_buckets(0), global_peak(1), duration_sec(0) { } @@ -70,15 +77,24 @@ struct TrackPeaks { delete[] mins; delete[] maxs; + delete[] low; + delete[] mid; + delete[] high; } void resize(int32_t n) { delete[] mins; delete[] maxs; + delete[] low; + delete[] mid; + delete[] high; num_buckets = n; mins = new int16_t[n](); maxs = new int16_t[n](); + low = new uint8_t[n](); + mid = new uint8_t[n](); + high = new uint8_t[n](); } }; @@ -114,21 +130,27 @@ static char * cache_path_for(const char * filename) return path; } +#define CACHE_MAGIC 0x57434232 + static bool load_cache(const char * path, TrackPeaks * out) { FILE * f = fopen(path, "rb"); if (!f) return false; - int32_t n = 0; - bool ok = fread(&n, sizeof n, 1, f) == 1 && n == NUM_BUCKETS; + int32_t magic = 0, n = 0; + bool ok = fread(&magic, sizeof magic, 1, f) == 1 && magic == CACHE_MAGIC && + fread(&n, sizeof n, 1, f) == 1 && n == NUM_BUCKETS; if (ok) { out->resize(n); ok = fread(&out->global_peak, sizeof out->global_peak, 1, f) == 1 && fread(&out->duration_sec, sizeof out->duration_sec, 1, f) == 1 && fread(out->mins, sizeof(int16_t), n, f) == (size_t)n && - fread(out->maxs, sizeof(int16_t), n, f) == (size_t)n; + fread(out->maxs, sizeof(int16_t), n, f) == (size_t)n && + fread(out->low, sizeof(uint8_t), n, f) == (size_t)n && + fread(out->mid, sizeof(uint8_t), n, f) == (size_t)n && + fread(out->high, sizeof(uint8_t), n, f) == (size_t)n; } fclose(f); return ok; @@ -139,12 +161,17 @@ static void save_cache(const char * path, const TrackPeaks * in) FILE * f = fopen(path, "wb"); if (!f) return; + int32_t magic = CACHE_MAGIC; int32_t n = in->num_buckets; + fwrite(&magic, sizeof magic, 1, f); fwrite(&n, sizeof n, 1, f); fwrite(&in->global_peak, sizeof in->global_peak, 1, f); fwrite(&in->duration_sec, sizeof in->duration_sec, 1, f); fwrite(in->mins, sizeof(int16_t), n, f); fwrite(in->maxs, sizeof(int16_t), n, f); + fwrite(in->low, sizeof(uint8_t), n, f); + fwrite(in->mid, sizeof(uint8_t), n, f); + fwrite(in->high, sizeof(uint8_t), n, f); fclose(f); } @@ -187,6 +214,82 @@ static void pump_frame(AVFrame * fr, PumpContext * ctx) } } +/* Splits the mono signal into bass/mid/treble via two simple one-pole + * filters (cheap, stable, no FFT needed -- this is the same order of + * rigor as the bass/mid/treble split used in most cheap VU-style + * visualizers, not a precise crossover). + * For each bucket, records the peak |amplitude| seen in each + * band, then normalizes each band independently against its own + * track-wide max so quiet hi-hats show up just as vividly as loud bass, + * instead of bass just visually dominating everything. */ +static void compute_band_peaks(const int16_t * samples, size_t count, + int sample_rate, int num_buckets, + uint8_t * low_out, uint8_t * mid_out, + uint8_t * high_out) +{ + const float fc_low = 250.0f; /* below this = "low" band */ + const float fc_high = + 4000.0f; /* above this = "high" band; in between = "mid" */ + float alpha_low = expf(-2.0f * (float)M_PI * fc_low / sample_rate); + float alpha_high = expf(-2.0f * (float)M_PI * fc_high / sample_rate); + + std::vector low_peak(num_buckets, 0.0f); + std::vector mid_peak(num_buckets, 0.0f); + std::vector high_peak(num_buckets, 0.0f); + + float low_state = 0.0f, high_state = 0.0f, prev_x = 0.0f; + + for (size_t i = 0; i < count; i++) + { + float x = (float)samples[i]; + + low_state = alpha_low * low_state + (1.0f - alpha_low) * x; + high_state = alpha_high * (high_state + x - prev_x); + prev_x = x; + + float low_val = low_state; + float high_val = high_state; + float mid_val = x - low_val - high_val; + + int b = (int)((double)i / count * num_buckets); + if (b >= num_buckets) + b = num_buckets - 1; + + float la = fabsf(low_val), ma = fabsf(mid_val), ha = fabsf(high_val); + if (la > low_peak[b]) + low_peak[b] = la; + if (ma > mid_peak[b]) + mid_peak[b] = ma; + if (ha > high_peak[b]) + high_peak[b] = ha; + } + + /* IMPORTANT: normalize against ONE shared max across all three bands, + * not each band's own max independently. Per-band normalization would + * make a pure bass tone's tiny mid/high residual energy *also* read + * as "fully lit" (since it's roughly constant across buckets, and + * constant/its-own-max ~= 1 everywhere) -- which defeats the purpose: + * we want color to reflect which band actually dominates at a given + * moment, not just each band's variation over time. */ + float shared_max = 1.0f; + for (int b = 0; b < num_buckets; b++) + { + if (low_peak[b] > shared_max) + shared_max = low_peak[b]; + if (mid_peak[b] > shared_max) + shared_max = mid_peak[b]; + if (high_peak[b] > shared_max) + shared_max = high_peak[b]; + } + + for (int b = 0; b < num_buckets; b++) + { + low_out[b] = (uint8_t)(255.0f * low_peak[b] / shared_max); + mid_out[b] = (uint8_t)(255.0f * mid_peak[b] / shared_max); + high_out[b] = (uint8_t)(255.0f * high_peak[b] / shared_max); + } +} + static bool decode_peaks(const char * filename, TrackPeaks * out) { AVFormatContext * fmt_ctx = nullptr; @@ -358,6 +461,9 @@ static bool decode_peaks(const char * filename, TrackPeaks * out) out->global_peak = mx; } + compute_band_peaks(pctx.samples, pctx.samples_count, sample_rate, + NUM_BUCKETS, out->low, out->mid, out->high); + delete[] pctx.samples; delete[] pctx.conv_buf; return true; @@ -439,6 +545,12 @@ void WaveformWidget::paint_waveform(QPainter & p) g_peaks->num_buckets * sizeof(int16_t)); memcpy(local_peaks.maxs, g_peaks->maxs, g_peaks->num_buckets * sizeof(int16_t)); + memcpy(local_peaks.low, g_peaks->low, + g_peaks->num_buckets * sizeof(uint8_t)); + memcpy(local_peaks.mid, g_peaks->mid, + g_peaks->num_buckets * sizeof(uint8_t)); + memcpy(local_peaks.high, g_peaks->high, + g_peaks->num_buckets * sizeof(uint8_t)); } pthread_mutex_unlock(&g_mutex); @@ -456,7 +568,9 @@ void WaveformWidget::paint_waveform(QPainter & p) int n = local_peaks.num_buckets; p.setPen(Qt::NoPen); - p.setBrush(QColor(64, 200, 255)); + + /* keep quiet bands faintly visible rather than pure black */ + const int floor_c = 40; for (int x = 0; x < w; x++) { @@ -471,6 +585,13 @@ void WaveformWidget::paint_waveform(QPainter & p) top = bot; bot = tmp; } + + /* low -> red, mid -> green, high -> blue, the same convention */ + int r = floor_c + (255 - floor_c) * local_peaks.low[idx] / 255; + int g = floor_c + (255 - floor_c) * local_peaks.mid[idx] / 255; + int b = floor_c + (255 - floor_c) * local_peaks.high[idx] / 255; + + p.setBrush(QColor(r, g, b)); p.drawRect(QRectF(x, top, 1.0, bot - top + 1.0)); } } From 13fc654caa22b1190979850b109461ed561e537c Mon Sep 17 00:00:00 2001 From: 0000xFFFF Date: Sat, 27 Jun 2026 00:35:01 +0200 Subject: [PATCH 5/7] preload button in settings move preload to settings menu remove comments ; reformat comment fallback --- src/waveform-qt/waveform_qt.cc | 184 +++++++++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 7 deletions(-) diff --git a/src/waveform-qt/waveform_qt.cc b/src/waveform-qt/waveform_qt.cc index d28aeaca2..24411358c 100644 --- a/src/waveform-qt/waveform_qt.cc +++ b/src/waveform-qt/waveform_qt.cc @@ -18,6 +18,7 @@ * POSSIBILITY OF SUCH DAMAGE. */ +#include #include #include #include @@ -25,14 +26,19 @@ #include #include #include +#include #include #include #include +#include +#include #include #include #include +#include #include +#include #include #include @@ -40,7 +46,9 @@ #include #include #include +#include #include +#include #include #include @@ -469,10 +477,153 @@ static bool decode_peaks(const char * filename, TrackPeaks * out) return true; } +// Playlist preload +static std::atomic g_preload_running{false}; +static std::atomic g_preload_total{0}; +static std::atomic g_preload_done{0}; + +static bool preload_one_file(const char * filename) +{ + TrackPeaks peaks; + char * cpath = cache_path_for(filename); + + bool ok = load_cache(cpath, &peaks); + if (!ok) + { + StringBuf local = uri_to_filename(filename); + const char * decode_path = local ? (const char *)local : filename; + + ok = decode_peaks(decode_path, &peaks); + if (ok) + save_cache(cpath, &peaks); + } + + delete[] cpath; + return ok; +} + +static void run_playlist_preload() +{ + if (g_preload_running.exchange(true)) + return; /* already running */ + + Playlist pl = Playlist::active_playlist(); + int n = pl.n_entries(); + + g_preload_total = n; + g_preload_done = 0; + + if (n <= 0) + { + g_preload_running = false; + return; + } + + auto * filenames = new std::vector(); + filenames->reserve(n); + for (int i = 0; i < n; i++) + { + String fn = pl.entry_filename(i); + filenames->push_back(strdup(fn ? (const char *)fn : "")); + } + + std::thread([filenames]() { + unsigned n_threads = std::thread::hardware_concurrency(); + if (n_threads == 0) + n_threads = 1; /* hardware_concurrency() can return 0; fall back */ + + size_t total = filenames->size(); + if (n_threads > total) + n_threads = (unsigned)total; + + std::atomic next_idx{0}; + + std::vector workers; + workers.reserve(n_threads); + for (unsigned t = 0; t < n_threads; t++) + { + workers.emplace_back([filenames, &next_idx, total]() { + for (;;) + { + size_t idx = next_idx.fetch_add(1); + if (idx >= total) + break; + + const char * fn = (*filenames)[idx]; + if (fn && fn[0]) + preload_one_file(fn); + + g_preload_done.fetch_add(1); + } + }); + } + + for (auto & w : workers) + w.join(); + + for (char * fn : *filenames) + free(fn); + delete filenames; + + g_preload_running = false; + }).detach(); +} + class WaveformWidget; static WaveformWidget * spect_widget = nullptr; +static QWidget * g_container = + nullptr; /* outer widget: controls bar + waveform */ static void load_track_async(const char * filename); +class WaveformControls : public QWidget +{ +public: + explicit WaveformControls(QWidget * parent = nullptr) : QWidget(parent) + { + auto * layout = new QHBoxLayout(this); + layout->setContentsMargins(4, 2, 4, 2); + + m_button = new QPushButton(_("Preload Playlist Waveforms"), this); + m_label = new QLabel(this); + m_label->setMinimumWidth(90); + + layout->addWidget(m_button); + layout->addWidget(m_label); + layout->addStretch(1); + + QObject::connect(m_button, &QPushButton::clicked, this, + []() { run_playlist_preload(); }); + + m_timer = new QTimer(this); + QObject::connect(m_timer, &QTimer::timeout, this, + [this]() { refresh(); }); + m_timer->start(150); + + refresh(); + } + +private: + void refresh() + { + bool running = g_preload_running.load(); + int done = g_preload_done.load(); + int total = g_preload_total.load(); + + m_button->setEnabled(!running); + + if (running) + m_label->setText(QString("%1/%2").arg(done).arg(total)); + else if (total > 0) + m_label->setText(QString(_("Done (%1/%2)")).arg(done).arg(total)); + else + m_label->setText(QString()); + } + + QPushButton * m_button; + QLabel * m_label; + QTimer * m_timer; +}; + class WaveformWidget : public QWidget { public: @@ -514,7 +665,11 @@ WaveformWidget::WaveformWidget(QWidget * parent) : QWidget(parent) } } -WaveformWidget::~WaveformWidget() { spect_widget = nullptr; } +WaveformWidget::~WaveformWidget() +{ + spect_widget = nullptr; + g_container = nullptr; +} void WaveformWidget::apply_peaks(const char * filename, TrackPeaks * peaks) { @@ -737,11 +892,18 @@ static void on_playback_ready(void *, void *) load_track_async((const char *)fn); } +static void * waveform_get_controls_widget() { return new WaveformControls(); } + +static const PreferencesWidget waveform_widgets[] = { + WidgetCustomQt(waveform_get_controls_widget)}; + +static const PluginPreferences waveform_prefs = {{waveform_widgets}}; + class QtWaveform : public VisPlugin { public: static constexpr PluginInfo info = {N_("Waveform"), PACKAGE, nullptr, - nullptr, PluginQtOnly}; + &waveform_prefs, PluginQtOnly}; constexpr QtWaveform() : VisPlugin(info, Visualizer::Freq) {} @@ -781,9 +943,17 @@ void QtWaveform::clear() void * QtWaveform::get_qt_widget() { - if (spect_widget) - return spect_widget; + if (g_container) + return g_container; - spect_widget = new WaveformWidget(); - return spect_widget; -} \ No newline at end of file + auto * container = new QWidget(); + auto * layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto * waveform = new WaveformWidget(); + layout->addWidget(waveform); + + g_container = container; + return container; +} From dbdb4e9e8351840fb49952210f89de150e0b0388 Mon Sep 17 00:00:00 2001 From: 0000xFFFF Date: Sat, 27 Jun 2026 23:09:09 +0200 Subject: [PATCH 6/7] SoC remove unused waveform_controls -> settings add lic --- src/waveform-qt/meson.build | 8 +- src/waveform-qt/preload.cc | 128 ++++ src/waveform-qt/preload.h | 45 ++ src/waveform-qt/settings.cc | 71 +++ src/waveform-qt/settings.h | 50 ++ src/waveform-qt/track_peaks.cc | 406 ++++++++++++ src/waveform-qt/track_peaks.h | 85 +++ src/waveform-qt/track_state.cc | 76 +++ src/waveform-qt/track_state.h | 42 ++ src/waveform-qt/waveform_plugin.cc | 84 +++ src/waveform-qt/waveform_qt.cc | 959 ----------------------------- src/waveform-qt/waveform_widget.cc | 249 ++++++++ src/waveform-qt/waveform_widget.h | 66 ++ 13 files changed, 1308 insertions(+), 961 deletions(-) create mode 100644 src/waveform-qt/preload.cc create mode 100644 src/waveform-qt/preload.h create mode 100644 src/waveform-qt/settings.cc create mode 100644 src/waveform-qt/settings.h create mode 100644 src/waveform-qt/track_peaks.cc create mode 100644 src/waveform-qt/track_peaks.h create mode 100644 src/waveform-qt/track_state.cc create mode 100644 src/waveform-qt/track_state.h create mode 100644 src/waveform-qt/waveform_plugin.cc delete mode 100644 src/waveform-qt/waveform_qt.cc create mode 100644 src/waveform-qt/waveform_widget.cc create mode 100644 src/waveform-qt/waveform_widget.h diff --git a/src/waveform-qt/meson.build b/src/waveform-qt/meson.build index 9c315a940..55fe97487 100644 --- a/src/waveform-qt/meson.build +++ b/src/waveform-qt/meson.build @@ -3,9 +3,13 @@ avcodec_dep = dependency('libavcodec') avutil_dep = dependency('libavutil') swresample_dep = dependency('libswresample') threads_dep = dependency('threads') - shared_module('waveform-qt', - 'waveform_qt.cc', + 'track_peaks.cc', + 'preload.cc', + 'track_state.cc', + 'waveform_widget.cc', + 'settings.cc', + 'waveform_plugin.cc', dependencies: [audacious_dep, qt_dep, avformat_dep, avcodec_dep, avutil_dep, swresample_dep, threads_dep], name_prefix: '', install: true, diff --git a/src/waveform-qt/preload.cc b/src/waveform-qt/preload.cc new file mode 100644 index 000000000..99fed04c3 --- /dev/null +++ b/src/waveform-qt/preload.cc @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "preload.h" + +#include +#include +#include +#include +#include + +#include +#include + +std::atomic g_preload_running{false}; +std::atomic g_preload_total{0}; +std::atomic g_preload_done{0}; + +bool preload_one_file(const char * filename, TrackPeaks * out) +{ + TrackPeaks local; + TrackPeaks * peaks = out ? out : &local; + + char * cpath = cache_path_for(filename); + + bool ok = load_cache(cpath, peaks); + if (!ok) + { + StringBuf local_path = uri_to_filename(filename); + const char * decode_path = + local_path ? (const char *)local_path : filename; + + ok = decode_peaks(decode_path, peaks); + if (ok) + save_cache(cpath, peaks); + } + + delete[] cpath; + return ok; +} + +/* Filenames are snapshotted on the calling (UI) thread -- Playlist's + * API is not meant to be called from worker threads -- then handed off + * to the worker pool below, so a beefier machine churns through the + * playlist proportionally faster. */ +void run_playlist_preload() +{ + if (g_preload_running.exchange(true)) + return; /* already running */ + + Playlist pl = Playlist::active_playlist(); + int n = pl.n_entries(); + + g_preload_total = n; + g_preload_done = 0; + + if (n <= 0) + { + g_preload_running = false; + return; + } + + auto * filenames = new std::vector(); + filenames->reserve(n); + for (int i = 0; i < n; i++) + { + String fn = pl.entry_filename(i); + filenames->push_back(strdup(fn ? (const char *)fn : "")); + } + + std::thread([filenames]() { + unsigned n_threads = std::thread::hardware_concurrency(); + if (n_threads == 0) + n_threads = 2; /* hardware_concurrency() can return 0; fall back */ + + size_t total = filenames->size(); + if (n_threads > total) + n_threads = (unsigned)total; + + std::atomic next_idx{0}; + + std::vector workers; + workers.reserve(n_threads); + for (unsigned t = 0; t < n_threads; t++) + { + workers.emplace_back([filenames, &next_idx, total]() { + for (;;) + { + size_t idx = next_idx.fetch_add(1); + if (idx >= total) + break; + + const char * fn = (*filenames)[idx]; + if (fn && fn[0]) + preload_one_file(fn); + + g_preload_done.fetch_add(1); + } + }); + } + + for (auto & w : workers) + w.join(); + + for (char * fn : *filenames) + free(fn); + delete filenames; + + g_preload_running = false; + }).detach(); +} diff --git a/src/waveform-qt/preload.h b/src/waveform-qt/preload.h new file mode 100644 index 000000000..80f3fb013 --- /dev/null +++ b/src/waveform-qt/preload.h @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef __PRELOAD_H__ +#define __PRELOAD_H__ + +#include + +#include "track_peaks.h" + +/* Progress counters, polled by the controls widget's status label. */ +extern std::atomic g_preload_running; +extern std::atomic g_preload_total; +extern std::atomic g_preload_done; + +/* Loads peaks for `filename` from cache if possible, otherwise decodes + * and writes the cache. If `out` is non-null, the resulting peak data + * is copied into it; otherwise it is decoded/loaded and then discarded. + * Shared by the bulk preloader and the single-track async loader. */ +bool preload_one_file(const char * filename, TrackPeaks * out = nullptr); + +/* Kicks off a background preload of every entry in the active + * playlist using a pool of worker threads sized to the number of CPU + * cores available. Safe to call repeatedly; a second call while one + * is already running is ignored. */ +void run_playlist_preload(); + +#endif diff --git a/src/waveform-qt/settings.cc b/src/waveform-qt/settings.cc new file mode 100644 index 000000000..a3d2c68e3 --- /dev/null +++ b/src/waveform-qt/settings.cc @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "settings.h" + +#include +#include +#include +#include + +#include + +#include "preload.h" + +WaveformSettings::WaveformSettings(QWidget * parent) : QWidget(parent) +{ + auto * layout = new QHBoxLayout(this); + layout->setContentsMargins(4, 2, 4, 2); + + m_button = new QPushButton(_("Preload Playlist Waveforms"), this); + m_label = new QLabel(this); + m_label->setMinimumWidth(90); + + layout->addWidget(m_button); + layout->addWidget(m_label); + layout->addStretch(1); + + QObject::connect(m_button, &QPushButton::clicked, this, + []() { run_playlist_preload(); }); + + m_timer = new QTimer(this); + QObject::connect(m_timer, &QTimer::timeout, this, [this]() { refresh(); }); + m_timer->start(150); + + refresh(); +} + +void WaveformSettings::refresh() +{ + bool running = g_preload_running.load(); + int done = g_preload_done.load(); + int total = g_preload_total.load(); + + m_button->setEnabled(!running); + + if (running) + m_label->setText(QString("%1/%2").arg(done).arg(total)); + else if (total > 0) + m_label->setText(QString(_("Done (%1/%2)")).arg(done).arg(total)); + else + m_label->setText(QString()); +} + +void * waveform_get_settings_widget() { return new WaveformSettings(); } diff --git a/src/waveform-qt/settings.h b/src/waveform-qt/settings.h new file mode 100644 index 000000000..d62609453 --- /dev/null +++ b/src/waveform-qt/settings.h @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef __WAVEFORM_CONTROLS_H__ +#define __WAVEFORM_CONTROLS_H__ + +#include + +class QPushButton; +class QLabel; +class QTimer; + +/* Shown on the plugin's preferences page: a button to preload the + * whole playlist's waveforms, and a label that polls preload progress + * while it runs. */ +class WaveformSettings : public QWidget +{ +public: + explicit WaveformSettings(QWidget * parent = nullptr); + +private: + void refresh(); + + QPushButton * m_button; + QLabel * m_label; + QTimer * m_timer; +}; + +/* Factory matching the signature WidgetCustomQt() expects in + * libaudcore/preferences.h. */ +void * waveform_get_settings_widget(); + +#endif diff --git a/src/waveform-qt/track_peaks.cc b/src/waveform-qt/track_peaks.cc new file mode 100644 index 000000000..e9ad03d8c --- /dev/null +++ b/src/waveform-qt/track_peaks.cc @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "track_peaks.h" + +#include +#include +#include +#include + +#include +#include + +#include + +extern "C" { +#include +#include +#include +#include +} + +char * cache_path_for(const char * filename) +{ + /* simple FNV-1a hash of the path -> cache filename */ + uint64_t h = 1469598103934665603ULL; + for (int i = 0; filename[i] != '\0'; i++) + { + h ^= (unsigned char)filename[i]; + h *= 1099511628211ULL; + } + + char hex[17]; + snprintf(hex, sizeof hex, "%016llx", (unsigned long long)h); + + const char * user_dir = aud_get_path(AudPath::UserDir); + int dir_len = strlen(user_dir) + 16; + char * dir = new char[dir_len]; + snprintf(dir, dir_len, "%s/waveform_cache", user_dir); + + QDir().mkpath(QString::fromUtf8(dir)); + + int path_len = strlen(dir) + 24; + char * path = new char[path_len]; + snprintf(path, path_len, "%s/%s.peaks", dir, hex); + + delete[] dir; + return path; +} + +#define CACHE_MAGIC 0x57434232 + +bool load_cache(const char * path, TrackPeaks * out) +{ + FILE * f = fopen(path, "rb"); + if (!f) + return false; + + int32_t magic = 0, n = 0; + bool ok = fread(&magic, sizeof magic, 1, f) == 1 && magic == CACHE_MAGIC && + fread(&n, sizeof n, 1, f) == 1 && n == NUM_BUCKETS; + if (ok) + { + out->resize(n); + ok = fread(&out->global_peak, sizeof out->global_peak, 1, f) == 1 && + fread(&out->duration_sec, sizeof out->duration_sec, 1, f) == 1 && + fread(out->mins, sizeof(int16_t), n, f) == (size_t)n && + fread(out->maxs, sizeof(int16_t), n, f) == (size_t)n && + fread(out->low, sizeof(uint8_t), n, f) == (size_t)n && + fread(out->mid, sizeof(uint8_t), n, f) == (size_t)n && + fread(out->high, sizeof(uint8_t), n, f) == (size_t)n; + } + fclose(f); + return ok; +} + +void save_cache(const char * path, const TrackPeaks * in) +{ + FILE * f = fopen(path, "wb"); + if (!f) + return; + int32_t magic = CACHE_MAGIC; + int32_t n = in->num_buckets; + fwrite(&magic, sizeof magic, 1, f); + fwrite(&n, sizeof n, 1, f); + fwrite(&in->global_peak, sizeof in->global_peak, 1, f); + fwrite(&in->duration_sec, sizeof in->duration_sec, 1, f); + fwrite(in->mins, sizeof(int16_t), n, f); + fwrite(in->maxs, sizeof(int16_t), n, f); + fwrite(in->low, sizeof(uint8_t), n, f); + fwrite(in->mid, sizeof(uint8_t), n, f); + fwrite(in->high, sizeof(uint8_t), n, f); + fclose(f); +} + +namespace +{ + +struct PumpContext +{ + int16_t * samples; + size_t samples_count; + size_t samples_capacity; + SwrContext * swr; + int16_t * conv_buf; + int conv_buf_capacity; +}; + +void pump_frame(AVFrame * fr, PumpContext * ctx) +{ + int max_out = swr_get_out_samples(ctx->swr, fr->nb_samples); + if (ctx->conv_buf_capacity < max_out) + { + delete[] ctx->conv_buf; + ctx->conv_buf_capacity = max_out; + ctx->conv_buf = new int16_t[max_out]; + } + uint8_t * planes[1] = {(uint8_t *)ctx->conv_buf}; + int got = swr_convert(ctx->swr, planes, max_out, (const uint8_t **)fr->data, + fr->nb_samples); + if (got > 0) + { + if (ctx->samples_count + got > ctx->samples_capacity) + { + ctx->samples_capacity = (ctx->samples_count + got) * 2; + int16_t * new_samples = new int16_t[ctx->samples_capacity]; + memcpy(new_samples, ctx->samples, + ctx->samples_count * sizeof(int16_t)); + delete[] ctx->samples; + ctx->samples = new_samples; + } + memcpy(ctx->samples + ctx->samples_count, ctx->conv_buf, + got * sizeof(int16_t)); + ctx->samples_count += got; + } +} + +/* Splits the mono signal into bass/mid/treble via two simple one-pole + * filters (cheap, stable, no FFT needed -- this is the same order of + * rigor as the bass/mid/treble split used in most cheap VU-style + * visualizers, not a precise crossover). + * For each bucket, records the peak |amplitude| seen in each + * band, then normalizes against a shared track-wide max so quiet + * hi-hats show up just as vividly as loud bass. */ +void compute_band_peaks(const int16_t * samples, size_t count, int sample_rate, + int num_buckets, uint8_t * low_out, uint8_t * mid_out, + uint8_t * high_out) +{ + const float fc_low = 250.0f; /* below this = "low" band */ + const float fc_high = + 4000.0f; /* above this = "high" band; in between = "mid" */ + float alpha_low = expf(-2.0f * (float)M_PI * fc_low / sample_rate); + float alpha_high = expf(-2.0f * (float)M_PI * fc_high / sample_rate); + + std::vector low_peak(num_buckets, 0.0f); + std::vector mid_peak(num_buckets, 0.0f); + std::vector high_peak(num_buckets, 0.0f); + + float low_state = 0.0f, high_state = 0.0f, prev_x = 0.0f; + + for (size_t i = 0; i < count; i++) + { + float x = (float)samples[i]; + + low_state = alpha_low * low_state + (1.0f - alpha_low) * x; + high_state = alpha_high * (high_state + x - prev_x); + prev_x = x; + + float low_val = low_state; + float high_val = high_state; + float mid_val = x - low_val - high_val; + + int b = (int)((double)i / count * num_buckets); + if (b >= num_buckets) + b = num_buckets - 1; + + float la = fabsf(low_val), ma = fabsf(mid_val), ha = fabsf(high_val); + if (la > low_peak[b]) + low_peak[b] = la; + if (ma > mid_peak[b]) + mid_peak[b] = ma; + if (ha > high_peak[b]) + high_peak[b] = ha; + } + + /* IMPORTANT: normalize against ONE shared max across all three bands, + * not each band's own max independently. Per-band normalization would + * make a pure bass tone's tiny mid/high residual energy *also* read + * as "fully lit" -- which defeats the purpose: we want color to + * reflect which band actually dominates at a given moment. */ + float shared_max = 1.0f; + for (int b = 0; b < num_buckets; b++) + { + if (low_peak[b] > shared_max) + shared_max = low_peak[b]; + if (mid_peak[b] > shared_max) + shared_max = mid_peak[b]; + if (high_peak[b] > shared_max) + shared_max = high_peak[b]; + } + + for (int b = 0; b < num_buckets; b++) + { + low_out[b] = (uint8_t)(255.0f * low_peak[b] / shared_max); + mid_out[b] = (uint8_t)(255.0f * mid_peak[b] / shared_max); + high_out[b] = (uint8_t)(255.0f * high_peak[b] / shared_max); + } +} + +} // namespace + +bool decode_peaks(const char * filename, TrackPeaks * out) +{ + AVFormatContext * fmt_ctx = nullptr; + if (avformat_open_input(&fmt_ctx, filename, nullptr, nullptr) < 0) + return false; + if (avformat_find_stream_info(fmt_ctx, nullptr) < 0) + { + avformat_close_input(&fmt_ctx); + return false; + } + + int audio_idx = -1; + for (unsigned i = 0; i < fmt_ctx->nb_streams; i++) + if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) + { + audio_idx = i; + break; + } + + if (audio_idx < 0) + { + avformat_close_input(&fmt_ctx); + return false; + } + + AVCodecParameters * params = fmt_ctx->streams[audio_idx]->codecpar; + const AVCodec * codec = avcodec_find_decoder(params->codec_id); + if (!codec) + { + avformat_close_input(&fmt_ctx); + return false; + } + + AVCodecContext * ctx = avcodec_alloc_context3(codec); + avcodec_parameters_to_context(ctx, params); + if (avcodec_open2(ctx, codec, nullptr) < 0) + { + avcodec_free_context(&ctx); + avformat_close_input(&fmt_ctx); + return false; + } + + SwrContext * swr = nullptr; + +#if LIBAVCODEC_VERSION_INT >= AV_VERSION_INT(59, 37, 100) + /* Modern FFmpeg (>= 5.1) Channel Layout API */ + AVChannelLayout out_layout; + av_channel_layout_default(&out_layout, 1); + + if (swr_alloc_set_opts2(&swr, &out_layout, AV_SAMPLE_FMT_S16, + ctx->sample_rate, &ctx->ch_layout, ctx->sample_fmt, + ctx->sample_rate, 0, nullptr) < 0 || + !swr || swr_init(swr) < 0) + { + avcodec_free_context(&ctx); + avformat_close_input(&fmt_ctx); + return false; + } +#else + /* Legacy FFmpeg (< 5.1) Channel Layout API */ + uint64_t out_layout = AV_CH_LAYOUT_MONO; + uint64_t in_layout = ctx->channel_layout; + + if (!in_layout) + in_layout = av_get_default_channel_layout(ctx->channels); + + swr = swr_alloc_set_opts(nullptr, out_layout, AV_SAMPLE_FMT_S16, + ctx->sample_rate, in_layout, ctx->sample_fmt, + ctx->sample_rate, 0, nullptr); + + if (!swr || swr_init(swr) < 0) + { + if (swr) + swr_free(&swr); + avcodec_free_context(&ctx); + avformat_close_input(&fmt_ctx); + return false; + } +#endif + + PumpContext pctx; + pctx.samples_capacity = (size_t)ctx->sample_rate * 60 * 4; + pctx.samples = new int16_t[pctx.samples_capacity]; + pctx.samples_count = 0; + pctx.swr = swr; + pctx.conv_buf_capacity = 1024; + pctx.conv_buf = new int16_t[pctx.conv_buf_capacity]; + + AVPacket * pkt = av_packet_alloc(); + AVFrame * frame = av_frame_alloc(); + + while (av_read_frame(fmt_ctx, pkt) >= 0) + { + if (pkt->stream_index == audio_idx && + avcodec_send_packet(ctx, pkt) == 0) + while (avcodec_receive_frame(ctx, frame) == 0) + pump_frame(frame, &pctx); + av_packet_unref(pkt); + } + avcodec_send_packet(ctx, nullptr); + while (avcodec_receive_frame(ctx, frame) == 0) + pump_frame(frame, &pctx); + + int drain; + do + { + uint8_t * planes[1] = {(uint8_t *)pctx.conv_buf}; + drain = swr_convert(swr, planes, pctx.conv_buf_capacity, nullptr, 0); + if (drain > 0) + { + if (pctx.samples_count + drain > pctx.samples_capacity) + { + pctx.samples_capacity = (pctx.samples_count + drain) * 2; + int16_t * new_samples = new int16_t[pctx.samples_capacity]; + memcpy(new_samples, pctx.samples, + pctx.samples_count * sizeof(int16_t)); + delete[] pctx.samples; + pctx.samples = new_samples; + } + memcpy(pctx.samples + pctx.samples_count, pctx.conv_buf, + drain * sizeof(int16_t)); + pctx.samples_count += drain; + } + } while (drain > 0); + + av_frame_free(&frame); + av_packet_free(&pkt); + swr_free(&swr); + avcodec_free_context(&ctx); + int sample_rate = fmt_ctx->streams[audio_idx]->codecpar->sample_rate; + avformat_close_input(&fmt_ctx); + + if (pctx.samples_count == 0) + { + delete[] pctx.samples; + delete[] pctx.conv_buf; + return false; + } + + out->resize(NUM_BUCKETS); + out->global_peak = 1; + out->duration_sec = (double)pctx.samples_count / sample_rate; + + for (int b = 0; b < NUM_BUCKETS; b++) + { + size_t start = (size_t)((double)b / NUM_BUCKETS * pctx.samples_count); + size_t end = + (size_t)((double)(b + 1) / NUM_BUCKETS * pctx.samples_count); + if (end > pctx.samples_count) + end = pctx.samples_count; + if (end <= start) + end = start + 1; + if (end > pctx.samples_count) + end = pctx.samples_count; + + int16_t mn = 0, mx = 0; + for (size_t i = start; i < end; i++) + { + if (pctx.samples[i] < mn) + mn = pctx.samples[i]; + if (pctx.samples[i] > mx) + mx = pctx.samples[i]; + } + out->mins[b] = mn; + out->maxs[b] = mx; + if (-mn > out->global_peak) + out->global_peak = -mn; + if (mx > out->global_peak) + out->global_peak = mx; + } + + compute_band_peaks(pctx.samples, pctx.samples_count, sample_rate, + NUM_BUCKETS, out->low, out->mid, out->high); + + delete[] pctx.samples; + delete[] pctx.conv_buf; + return true; +} diff --git a/src/waveform-qt/track_peaks.h b/src/waveform-qt/track_peaks.h new file mode 100644 index 000000000..732ad7675 --- /dev/null +++ b/src/waveform-qt/track_peaks.h @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef __TRACK_PEAKS_H__ +#define __TRACK_PEAKS_H__ + +#include + +#define NUM_BUCKETS 2000 + +/* per-bucket band intensities, 0-255, normalized against a shared max + * across all three bands (see compute_band_peaks in the .cpp for why + * shared rather than per-band normalization is used) */ +struct TrackPeaks +{ + int16_t * mins; + int16_t * maxs; + uint8_t * low; /* bass energy, -> red channel */ + uint8_t * mid; /* mid energy, -> green channel */ + uint8_t * high; /* treble energy -> blue channel */ + int32_t num_buckets; + int32_t global_peak; + double duration_sec; + + TrackPeaks() + : mins(nullptr), maxs(nullptr), low(nullptr), mid(nullptr), + high(nullptr), num_buckets(0), global_peak(1), duration_sec(0) + { + } + + ~TrackPeaks() + { + delete[] mins; + delete[] maxs; + delete[] low; + delete[] mid; + delete[] high; + } + + void resize(int32_t n) + { + delete[] mins; + delete[] maxs; + delete[] low; + delete[] mid; + delete[] high; + num_buckets = n; + mins = new int16_t[n](); + maxs = new int16_t[n](); + low = new uint8_t[n](); + mid = new uint8_t[n](); + high = new uint8_t[n](); + } +}; + +/* Decodes the given audio file into a fresh peak envelope. `filename` + * must already be a local filesystem path (callers resolve URIs via + * uri_to_filename() before calling this). Returns false on failure. */ +bool decode_peaks(const char * filename, TrackPeaks * out); + +/* Returns the on-disk cache path for `filename`. Caller owns the + * returned buffer and must delete[] it. */ +char * cache_path_for(const char * filename); + +bool load_cache(const char * path, TrackPeaks * out); +void save_cache(const char * path, const TrackPeaks * in); + +#endif diff --git a/src/waveform-qt/track_state.cc b/src/waveform-qt/track_state.cc new file mode 100644 index 000000000..469ae459c --- /dev/null +++ b/src/waveform-qt/track_state.cc @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "track_state.h" + +#include +#include + +static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; +static TrackPeaks * g_peaks = nullptr; +static char * g_current_file = nullptr; + +void track_state_cleanup() +{ + pthread_mutex_lock(&g_mutex); + delete[] g_current_file; + g_current_file = nullptr; + delete g_peaks; + g_peaks = nullptr; + pthread_mutex_unlock(&g_mutex); +} + +bool track_state_is_current(const char * filename) +{ + pthread_mutex_lock(&g_mutex); + bool same = g_current_file && strcmp(filename, g_current_file) == 0; + pthread_mutex_unlock(&g_mutex); + return same; +} + +void track_state_set(const char * filename, TrackPeaks * peaks) +{ + pthread_mutex_lock(&g_mutex); + delete[] g_current_file; + g_current_file = strdup(filename); + delete g_peaks; + g_peaks = peaks; + pthread_mutex_unlock(&g_mutex); +} + +void track_state_copy_into(TrackPeaks * out) +{ + pthread_mutex_lock(&g_mutex); + if (g_peaks && g_peaks->num_buckets > 0) + { + out->resize(g_peaks->num_buckets); + out->global_peak = g_peaks->global_peak; + out->duration_sec = g_peaks->duration_sec; + memcpy(out->mins, g_peaks->mins, + g_peaks->num_buckets * sizeof(int16_t)); + memcpy(out->maxs, g_peaks->maxs, + g_peaks->num_buckets * sizeof(int16_t)); + memcpy(out->low, g_peaks->low, g_peaks->num_buckets * sizeof(uint8_t)); + memcpy(out->mid, g_peaks->mid, g_peaks->num_buckets * sizeof(uint8_t)); + memcpy(out->high, g_peaks->high, + g_peaks->num_buckets * sizeof(uint8_t)); + } + pthread_mutex_unlock(&g_mutex); +} diff --git a/src/waveform-qt/track_state.h b/src/waveform-qt/track_state.h new file mode 100644 index 000000000..c974ded39 --- /dev/null +++ b/src/waveform-qt/track_state.h @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef __TRACK_STATE_H__ +#define __TRACK_STATE_H__ + +#include "track_peaks.h" + +/* Thread-safe storage for the peak envelope of the currently displayed + * track. Callers never touch a mutex directly -- everything goes + * through these functions. */ + +void track_state_cleanup(); + +/* True if `filename` matches the file currently stored. */ +bool track_state_is_current(const char * filename); + +/* Takes ownership of `peaks`, replacing whatever was stored before. */ +void track_state_set(const char * filename, TrackPeaks * peaks); + +/* Deep-copies the current peaks into `out`. If nothing is loaded, + * out->num_buckets is left at 0. */ +void track_state_copy_into(TrackPeaks * out); + +#endif diff --git a/src/waveform-qt/waveform_plugin.cc b/src/waveform-qt/waveform_plugin.cc new file mode 100644 index 000000000..ca57ec4cc --- /dev/null +++ b/src/waveform-qt/waveform_plugin.cc @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include /* must come before libaudcore/i18n.h to prevent macro-poisoning */ + +#include +#include +#include +#include +#include +#include + +#include "settings.h" +#include "track_state.h" +#include "waveform_widget.h" + +static void on_playback_ready(void *, void *) +{ + String fn = aud_drct_get_filename(); + if (fn) + load_track_async((const char *)fn); +} + +static const PreferencesWidget waveform_widgets[] = { + WidgetCustomQt(waveform_get_settings_widget)}; + +static const PluginPreferences waveform_prefs = {{waveform_widgets}}; + +class QtWaveform : public VisPlugin +{ +public: + static constexpr PluginInfo info = {N_("Waveform"), PACKAGE, nullptr, + &waveform_prefs, PluginQtOnly}; + + constexpr QtWaveform() : VisPlugin(info, Visualizer::Freq) {} + + bool init() override; + void cleanup() override; + void * get_qt_widget() override; + void clear() override; + void render_freq(const float * freq) override; +}; + +EXPORT QtWaveform aud_plugin_instance; + +bool QtWaveform::init() +{ + hook_associate("playback ready", (HookFunction)on_playback_ready, nullptr); + return true; +} + +void QtWaveform::cleanup() +{ + hook_dissociate("playback ready", (HookFunction)on_playback_ready); + track_state_cleanup(); +} + +void QtWaveform::render_freq(const float * freq) {} + +void QtWaveform::clear() +{ + WaveformWidget * w = waveform_get_active_widget(); + if (w) + w->update(); +} + +void * QtWaveform::get_qt_widget() { return waveform_get_container_widget(); } diff --git a/src/waveform-qt/waveform_qt.cc b/src/waveform-qt/waveform_qt.cc deleted file mode 100644 index 24411358c..000000000 --- a/src/waveform-qt/waveform_qt.cc +++ /dev/null @@ -1,959 +0,0 @@ -/* - * Copyright (c) 2026 0000xFFFF. - * - * Permission to use, copy, modify, and/or distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, - * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) - * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, - * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING - * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ - -#include -#include -#include -#include -#include /* must come before libaudcore/i18n.h to prevent macro-poisoning */ -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -extern "C" { -#include -#include -#include -#include -} - -#define NUM_BUCKETS 2000 - -/* per-bucket band intensities, 0-255, normalized independently per band so - * each color channel uses the full range (this is what gives quiet - * high-hats/cymbals visible color instead of bass just drowning them out) */ -struct TrackPeaks -{ - int16_t * mins; - int16_t * maxs; - uint8_t * low; /* bass energy, -> red channel */ - uint8_t * mid; /* mid energy, -> green channel */ - uint8_t * high; /* treble energy -> blue channel */ - int32_t num_buckets; - int32_t global_peak; - double duration_sec; - - TrackPeaks() - : mins(nullptr), maxs(nullptr), low(nullptr), mid(nullptr), - high(nullptr), num_buckets(0), global_peak(1), duration_sec(0) - { - } - - ~TrackPeaks() - { - delete[] mins; - delete[] maxs; - delete[] low; - delete[] mid; - delete[] high; - } - - void resize(int32_t n) - { - delete[] mins; - delete[] maxs; - delete[] low; - delete[] mid; - delete[] high; - num_buckets = n; - mins = new int16_t[n](); - maxs = new int16_t[n](); - low = new uint8_t[n](); - mid = new uint8_t[n](); - high = new uint8_t[n](); - } -}; - -static pthread_mutex_t g_mutex = PTHREAD_MUTEX_INITIALIZER; -static TrackPeaks * g_peaks = nullptr; /* envelope currently displayed */ -static char * g_current_file = nullptr; /* file the envelope above belongs to */ - -static char * cache_path_for(const char * filename) -{ - /* simple FNV-1a hash of the path -> cache filename */ - uint64_t h = 1469598103934665603ULL; - for (int i = 0; filename[i] != '\0'; i++) - { - h ^= (unsigned char)filename[i]; - h *= 1099511628211ULL; - } - - char hex[17]; - snprintf(hex, sizeof hex, "%016llx", (unsigned long long)h); - - const char * user_dir = aud_get_path(AudPath::UserDir); - int dir_len = strlen(user_dir) + 16; - char * dir = new char[dir_len]; - snprintf(dir, dir_len, "%s/waveform_cache", user_dir); - - QDir().mkpath(QString::fromUtf8(dir)); - - int path_len = strlen(dir) + 24; - char * path = new char[path_len]; - snprintf(path, path_len, "%s/%s.peaks", dir, hex); - - delete[] dir; - return path; -} - -#define CACHE_MAGIC 0x57434232 - -static bool load_cache(const char * path, TrackPeaks * out) -{ - FILE * f = fopen(path, "rb"); - if (!f) - return false; - - int32_t magic = 0, n = 0; - bool ok = fread(&magic, sizeof magic, 1, f) == 1 && magic == CACHE_MAGIC && - fread(&n, sizeof n, 1, f) == 1 && n == NUM_BUCKETS; - if (ok) - { - out->resize(n); - ok = fread(&out->global_peak, sizeof out->global_peak, 1, f) == 1 && - fread(&out->duration_sec, sizeof out->duration_sec, 1, f) == 1 && - fread(out->mins, sizeof(int16_t), n, f) == (size_t)n && - fread(out->maxs, sizeof(int16_t), n, f) == (size_t)n && - fread(out->low, sizeof(uint8_t), n, f) == (size_t)n && - fread(out->mid, sizeof(uint8_t), n, f) == (size_t)n && - fread(out->high, sizeof(uint8_t), n, f) == (size_t)n; - } - fclose(f); - return ok; -} - -static void save_cache(const char * path, const TrackPeaks * in) -{ - FILE * f = fopen(path, "wb"); - if (!f) - return; - int32_t magic = CACHE_MAGIC; - int32_t n = in->num_buckets; - fwrite(&magic, sizeof magic, 1, f); - fwrite(&n, sizeof n, 1, f); - fwrite(&in->global_peak, sizeof in->global_peak, 1, f); - fwrite(&in->duration_sec, sizeof in->duration_sec, 1, f); - fwrite(in->mins, sizeof(int16_t), n, f); - fwrite(in->maxs, sizeof(int16_t), n, f); - fwrite(in->low, sizeof(uint8_t), n, f); - fwrite(in->mid, sizeof(uint8_t), n, f); - fwrite(in->high, sizeof(uint8_t), n, f); - fclose(f); -} - -struct PumpContext -{ - int16_t * samples; - size_t samples_count; - size_t samples_capacity; - SwrContext * swr; - int16_t * conv_buf; - int conv_buf_capacity; -}; - -static void pump_frame(AVFrame * fr, PumpContext * ctx) -{ - int max_out = swr_get_out_samples(ctx->swr, fr->nb_samples); - if (ctx->conv_buf_capacity < max_out) - { - delete[] ctx->conv_buf; - ctx->conv_buf_capacity = max_out; - ctx->conv_buf = new int16_t[max_out]; - } - uint8_t * planes[1] = {(uint8_t *)ctx->conv_buf}; - int got = swr_convert(ctx->swr, planes, max_out, (const uint8_t **)fr->data, - fr->nb_samples); - if (got > 0) - { - if (ctx->samples_count + got > ctx->samples_capacity) - { - ctx->samples_capacity = (ctx->samples_count + got) * 2; - int16_t * new_samples = new int16_t[ctx->samples_capacity]; - memcpy(new_samples, ctx->samples, - ctx->samples_count * sizeof(int16_t)); - delete[] ctx->samples; - ctx->samples = new_samples; - } - memcpy(ctx->samples + ctx->samples_count, ctx->conv_buf, - got * sizeof(int16_t)); - ctx->samples_count += got; - } -} - -/* Splits the mono signal into bass/mid/treble via two simple one-pole - * filters (cheap, stable, no FFT needed -- this is the same order of - * rigor as the bass/mid/treble split used in most cheap VU-style - * visualizers, not a precise crossover). - * For each bucket, records the peak |amplitude| seen in each - * band, then normalizes each band independently against its own - * track-wide max so quiet hi-hats show up just as vividly as loud bass, - * instead of bass just visually dominating everything. */ -static void compute_band_peaks(const int16_t * samples, size_t count, - int sample_rate, int num_buckets, - uint8_t * low_out, uint8_t * mid_out, - uint8_t * high_out) -{ - const float fc_low = 250.0f; /* below this = "low" band */ - const float fc_high = - 4000.0f; /* above this = "high" band; in between = "mid" */ - float alpha_low = expf(-2.0f * (float)M_PI * fc_low / sample_rate); - float alpha_high = expf(-2.0f * (float)M_PI * fc_high / sample_rate); - - std::vector low_peak(num_buckets, 0.0f); - std::vector mid_peak(num_buckets, 0.0f); - std::vector high_peak(num_buckets, 0.0f); - - float low_state = 0.0f, high_state = 0.0f, prev_x = 0.0f; - - for (size_t i = 0; i < count; i++) - { - float x = (float)samples[i]; - - low_state = alpha_low * low_state + (1.0f - alpha_low) * x; - high_state = alpha_high * (high_state + x - prev_x); - prev_x = x; - - float low_val = low_state; - float high_val = high_state; - float mid_val = x - low_val - high_val; - - int b = (int)((double)i / count * num_buckets); - if (b >= num_buckets) - b = num_buckets - 1; - - float la = fabsf(low_val), ma = fabsf(mid_val), ha = fabsf(high_val); - if (la > low_peak[b]) - low_peak[b] = la; - if (ma > mid_peak[b]) - mid_peak[b] = ma; - if (ha > high_peak[b]) - high_peak[b] = ha; - } - - /* IMPORTANT: normalize against ONE shared max across all three bands, - * not each band's own max independently. Per-band normalization would - * make a pure bass tone's tiny mid/high residual energy *also* read - * as "fully lit" (since it's roughly constant across buckets, and - * constant/its-own-max ~= 1 everywhere) -- which defeats the purpose: - * we want color to reflect which band actually dominates at a given - * moment, not just each band's variation over time. */ - float shared_max = 1.0f; - for (int b = 0; b < num_buckets; b++) - { - if (low_peak[b] > shared_max) - shared_max = low_peak[b]; - if (mid_peak[b] > shared_max) - shared_max = mid_peak[b]; - if (high_peak[b] > shared_max) - shared_max = high_peak[b]; - } - - for (int b = 0; b < num_buckets; b++) - { - low_out[b] = (uint8_t)(255.0f * low_peak[b] / shared_max); - mid_out[b] = (uint8_t)(255.0f * mid_peak[b] / shared_max); - high_out[b] = (uint8_t)(255.0f * high_peak[b] / shared_max); - } -} - -static bool decode_peaks(const char * filename, TrackPeaks * out) -{ - AVFormatContext * fmt_ctx = nullptr; - if (avformat_open_input(&fmt_ctx, filename, nullptr, nullptr) < 0) - return false; - if (avformat_find_stream_info(fmt_ctx, nullptr) < 0) - { - avformat_close_input(&fmt_ctx); - return false; - } - - int audio_idx = -1; - for (unsigned i = 0; i < fmt_ctx->nb_streams; i++) - if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) - { - audio_idx = i; - break; - } - - if (audio_idx < 0) - { - avformat_close_input(&fmt_ctx); - return false; - } - - AVCodecParameters * params = fmt_ctx->streams[audio_idx]->codecpar; - const AVCodec * codec = avcodec_find_decoder(params->codec_id); - if (!codec) - { - avformat_close_input(&fmt_ctx); - return false; - } - - AVCodecContext * ctx = avcodec_alloc_context3(codec); - avcodec_parameters_to_context(ctx, params); - if (avcodec_open2(ctx, codec, nullptr) < 0) - { - avcodec_free_context(&ctx); - avformat_close_input(&fmt_ctx); - return false; - } - - SwrContext * swr = nullptr; - -#if LIBAVCODEC_VERSION_INT >= AV_VERSION_INT(59, 37, 100) - /* Modern FFmpeg (>= 5.1) Channel Layout API */ - AVChannelLayout out_layout; - av_channel_layout_default(&out_layout, 1); - - if (swr_alloc_set_opts2(&swr, &out_layout, AV_SAMPLE_FMT_S16, - ctx->sample_rate, &ctx->ch_layout, ctx->sample_fmt, - ctx->sample_rate, 0, nullptr) < 0 || - !swr || swr_init(swr) < 0) - { - avcodec_free_context(&ctx); - avformat_close_input(&fmt_ctx); - return false; - } -#else - /* Legacy FFmpeg (< 5.1) Channel Layout API */ - uint64_t out_layout = AV_CH_LAYOUT_MONO; - uint64_t in_layout = ctx->channel_layout; - - if (!in_layout) - in_layout = av_get_default_channel_layout(ctx->channels); - - swr = swr_alloc_set_opts(nullptr, out_layout, AV_SAMPLE_FMT_S16, - ctx->sample_rate, in_layout, ctx->sample_fmt, - ctx->sample_rate, 0, nullptr); - - if (!swr || swr_init(swr) < 0) - { - if (swr) - swr_free(&swr); - avcodec_free_context(&ctx); - avformat_close_input(&fmt_ctx); - return false; - } -#endif - - PumpContext pctx; - pctx.samples_capacity = (size_t)ctx->sample_rate * 60 * 4; - pctx.samples = new int16_t[pctx.samples_capacity]; - pctx.samples_count = 0; - pctx.swr = swr; - pctx.conv_buf_capacity = 1024; - pctx.conv_buf = new int16_t[pctx.conv_buf_capacity]; - - AVPacket * pkt = av_packet_alloc(); - AVFrame * frame = av_frame_alloc(); - - while (av_read_frame(fmt_ctx, pkt) >= 0) - { - if (pkt->stream_index == audio_idx && - avcodec_send_packet(ctx, pkt) == 0) - while (avcodec_receive_frame(ctx, frame) == 0) - pump_frame(frame, &pctx); - av_packet_unref(pkt); - } - avcodec_send_packet(ctx, nullptr); - while (avcodec_receive_frame(ctx, frame) == 0) - pump_frame(frame, &pctx); - - int drain; - do - { - uint8_t * planes[1] = {(uint8_t *)pctx.conv_buf}; - drain = swr_convert(swr, planes, pctx.conv_buf_capacity, nullptr, 0); - if (drain > 0) - { - if (pctx.samples_count + drain > pctx.samples_capacity) - { - pctx.samples_capacity = (pctx.samples_count + drain) * 2; - int16_t * new_samples = new int16_t[pctx.samples_capacity]; - memcpy(new_samples, pctx.samples, - pctx.samples_count * sizeof(int16_t)); - delete[] pctx.samples; - pctx.samples = new_samples; - } - memcpy(pctx.samples + pctx.samples_count, pctx.conv_buf, - drain * sizeof(int16_t)); - pctx.samples_count += drain; - } - } while (drain > 0); - - av_frame_free(&frame); - av_packet_free(&pkt); - swr_free(&swr); - avcodec_free_context(&ctx); - int sample_rate = fmt_ctx->streams[audio_idx]->codecpar->sample_rate; - avformat_close_input(&fmt_ctx); - - if (pctx.samples_count == 0) - { - delete[] pctx.samples; - delete[] pctx.conv_buf; - return false; - } - - out->resize(NUM_BUCKETS); - out->global_peak = 1; - out->duration_sec = (double)pctx.samples_count / sample_rate; - - for (int b = 0; b < NUM_BUCKETS; b++) - { - size_t start = (size_t)((double)b / NUM_BUCKETS * pctx.samples_count); - size_t end = - (size_t)((double)(b + 1) / NUM_BUCKETS * pctx.samples_count); - if (end > pctx.samples_count) - end = pctx.samples_count; - if (end <= start) - end = start + 1; - if (end > pctx.samples_count) - end = pctx.samples_count; - - int16_t mn = 0, mx = 0; - for (size_t i = start; i < end; i++) - { - if (pctx.samples[i] < mn) - mn = pctx.samples[i]; - if (pctx.samples[i] > mx) - mx = pctx.samples[i]; - } - out->mins[b] = mn; - out->maxs[b] = mx; - if (-mn > out->global_peak) - out->global_peak = -mn; - if (mx > out->global_peak) - out->global_peak = mx; - } - - compute_band_peaks(pctx.samples, pctx.samples_count, sample_rate, - NUM_BUCKETS, out->low, out->mid, out->high); - - delete[] pctx.samples; - delete[] pctx.conv_buf; - return true; -} - -// Playlist preload -static std::atomic g_preload_running{false}; -static std::atomic g_preload_total{0}; -static std::atomic g_preload_done{0}; - -static bool preload_one_file(const char * filename) -{ - TrackPeaks peaks; - char * cpath = cache_path_for(filename); - - bool ok = load_cache(cpath, &peaks); - if (!ok) - { - StringBuf local = uri_to_filename(filename); - const char * decode_path = local ? (const char *)local : filename; - - ok = decode_peaks(decode_path, &peaks); - if (ok) - save_cache(cpath, &peaks); - } - - delete[] cpath; - return ok; -} - -static void run_playlist_preload() -{ - if (g_preload_running.exchange(true)) - return; /* already running */ - - Playlist pl = Playlist::active_playlist(); - int n = pl.n_entries(); - - g_preload_total = n; - g_preload_done = 0; - - if (n <= 0) - { - g_preload_running = false; - return; - } - - auto * filenames = new std::vector(); - filenames->reserve(n); - for (int i = 0; i < n; i++) - { - String fn = pl.entry_filename(i); - filenames->push_back(strdup(fn ? (const char *)fn : "")); - } - - std::thread([filenames]() { - unsigned n_threads = std::thread::hardware_concurrency(); - if (n_threads == 0) - n_threads = 1; /* hardware_concurrency() can return 0; fall back */ - - size_t total = filenames->size(); - if (n_threads > total) - n_threads = (unsigned)total; - - std::atomic next_idx{0}; - - std::vector workers; - workers.reserve(n_threads); - for (unsigned t = 0; t < n_threads; t++) - { - workers.emplace_back([filenames, &next_idx, total]() { - for (;;) - { - size_t idx = next_idx.fetch_add(1); - if (idx >= total) - break; - - const char * fn = (*filenames)[idx]; - if (fn && fn[0]) - preload_one_file(fn); - - g_preload_done.fetch_add(1); - } - }); - } - - for (auto & w : workers) - w.join(); - - for (char * fn : *filenames) - free(fn); - delete filenames; - - g_preload_running = false; - }).detach(); -} - -class WaveformWidget; -static WaveformWidget * spect_widget = nullptr; -static QWidget * g_container = - nullptr; /* outer widget: controls bar + waveform */ -static void load_track_async(const char * filename); - -class WaveformControls : public QWidget -{ -public: - explicit WaveformControls(QWidget * parent = nullptr) : QWidget(parent) - { - auto * layout = new QHBoxLayout(this); - layout->setContentsMargins(4, 2, 4, 2); - - m_button = new QPushButton(_("Preload Playlist Waveforms"), this); - m_label = new QLabel(this); - m_label->setMinimumWidth(90); - - layout->addWidget(m_button); - layout->addWidget(m_label); - layout->addStretch(1); - - QObject::connect(m_button, &QPushButton::clicked, this, - []() { run_playlist_preload(); }); - - m_timer = new QTimer(this); - QObject::connect(m_timer, &QTimer::timeout, this, - [this]() { refresh(); }); - m_timer->start(150); - - refresh(); - } - -private: - void refresh() - { - bool running = g_preload_running.load(); - int done = g_preload_done.load(); - int total = g_preload_total.load(); - - m_button->setEnabled(!running); - - if (running) - m_label->setText(QString("%1/%2").arg(done).arg(total)); - else if (total > 0) - m_label->setText(QString(_("Done (%1/%2)")).arg(done).arg(total)); - else - m_label->setText(QString()); - } - - QPushButton * m_button; - QLabel * m_label; - QTimer * m_timer; -}; - -class WaveformWidget : public QWidget -{ -public: - WaveformWidget(QWidget * parent = nullptr); - ~WaveformWidget(); - - void apply_peaks(const char * filename, TrackPeaks * peaks); - -protected: - void resizeEvent(QResizeEvent *) override; - void paintEvent(QPaintEvent *) override; - void mousePressEvent(QMouseEvent * event) override; - void mouseMoveEvent(QMouseEvent * event) override; - -private: - void paint_background(QPainter &); - void paint_waveform(QPainter &); - void paint_playhead(QPainter &); - void seek_to_x(int x); - - QTimer * m_timer; -}; - -WaveformWidget::WaveformWidget(QWidget * parent) : QWidget(parent) -{ - spect_widget = this; - - m_timer = new QTimer(this); - QObject::connect(m_timer, &QTimer::timeout, this, [this]() { update(); }); - m_timer->start(100); - - setMinimumHeight(80); - - if (aud_drct_get_ready()) - { - String fn = aud_drct_get_filename(); - if (fn) - load_track_async((const char *)fn); - } -} - -WaveformWidget::~WaveformWidget() -{ - spect_widget = nullptr; - g_container = nullptr; -} - -void WaveformWidget::apply_peaks(const char * filename, TrackPeaks * peaks) -{ - pthread_mutex_lock(&g_mutex); - delete[] g_current_file; - g_current_file = strdup(filename); - delete g_peaks; - g_peaks = peaks; - pthread_mutex_unlock(&g_mutex); - update(); -} - -void WaveformWidget::paint_background(QPainter & p) -{ - p.fillRect(0, 0, width(), height(), QColor(18, 18, 20)); -} - -void WaveformWidget::paint_waveform(QPainter & p) -{ - TrackPeaks local_peaks; - pthread_mutex_lock(&g_mutex); - if (g_peaks && g_peaks->num_buckets > 0) - { - local_peaks.resize(g_peaks->num_buckets); - local_peaks.global_peak = g_peaks->global_peak; - local_peaks.duration_sec = g_peaks->duration_sec; - memcpy(local_peaks.mins, g_peaks->mins, - g_peaks->num_buckets * sizeof(int16_t)); - memcpy(local_peaks.maxs, g_peaks->maxs, - g_peaks->num_buckets * sizeof(int16_t)); - memcpy(local_peaks.low, g_peaks->low, - g_peaks->num_buckets * sizeof(uint8_t)); - memcpy(local_peaks.mid, g_peaks->mid, - g_peaks->num_buckets * sizeof(uint8_t)); - memcpy(local_peaks.high, g_peaks->high, - g_peaks->num_buckets * sizeof(uint8_t)); - } - pthread_mutex_unlock(&g_mutex); - - int w = width(), h = height(); - int center_y = h / 2; - - p.setPen(QColor(90, 90, 95)); - p.drawLine(0, center_y, w, center_y); - - if (local_peaks.num_buckets == 0) - return; - - double half = h / 2.0; - double scale = (half * 0.95) / (double)local_peaks.global_peak; - int n = local_peaks.num_buckets; - - p.setPen(Qt::NoPen); - - /* keep quiet bands faintly visible rather than pure black */ - const int floor_c = 40; - - for (int x = 0; x < w; x++) - { - int idx = x * n / w; - if (idx >= n) - idx = n - 1; - double top = center_y - local_peaks.maxs[idx] * scale; - double bot = center_y - local_peaks.mins[idx] * scale; - if (bot < top) - { - double tmp = top; - top = bot; - bot = tmp; - } - - /* low -> red, mid -> green, high -> blue, the same convention */ - int r = floor_c + (255 - floor_c) * local_peaks.low[idx] / 255; - int g = floor_c + (255 - floor_c) * local_peaks.mid[idx] / 255; - int b = floor_c + (255 - floor_c) * local_peaks.high[idx] / 255; - - p.setBrush(QColor(r, g, b)); - p.drawRect(QRectF(x, top, 1.0, bot - top + 1.0)); - } -} - -void WaveformWidget::paint_playhead(QPainter & p) -{ - if (!aud_drct_get_playing()) - return; - - int length = aud_drct_get_length(); - if (length <= 0) - return; - - int time = aud_drct_get_time(); - double frac = (double)time / length; - if (frac < 0) - frac = 0; - if (frac > 1) - frac = 1; - - double x = frac * width(); - - QPen pen(QColor(255, 40, 40)); - pen.setWidth(2); - p.setPen(pen); - p.drawLine(QPointF(x, 0), QPointF(x, height())); -} - -void WaveformWidget::resizeEvent(QResizeEvent *) { update(); } - -void WaveformWidget::paintEvent(QPaintEvent *) -{ - QPainter p(this); - paint_background(p); - paint_waveform(p); - paint_playhead(p); -} - -void WaveformWidget::seek_to_x(int x) -{ - int length = aud_drct_get_length(); - if (length <= 0 || width() <= 0) - return; - - double frac = (double)x / width(); - if (frac < 0) - frac = 0; - if (frac > 1) - frac = 1; - - aud_drct_seek((int)(frac * length)); - update(); -} - -void WaveformWidget::mousePressEvent(QMouseEvent * event) -{ - if (event->button() == Qt::LeftButton) - seek_to_x(event->pos().x()); -} - -void WaveformWidget::mouseMoveEvent(QMouseEvent * event) -{ - if (event->buttons() & Qt::LeftButton) - seek_to_x(event->pos().x()); -} - -// async loader thread helper -static void * worker_thread_func(void * arg) -{ - char * filename = (char *)arg; - TrackPeaks * peaks = new TrackPeaks(); - char * cpath = cache_path_for(filename); - - bool ok = load_cache(cpath, peaks); - if (!ok) - { - StringBuf local = uri_to_filename(filename); - const char * decode_path = local ? (const char *)local : filename; - - ok = decode_peaks(decode_path, peaks); - if (ok) - save_cache(cpath, peaks); - } - delete[] cpath; - - if (!ok) - { - delete peaks; - free(filename); - return nullptr; - } - - /* dynamic copies passed along safely to the Qt event loop lambda */ - char * fn_copy = strdup(filename); - QMetaObject::invokeMethod( - qApp, - [fn_copy, peaks]() { - if (spect_widget) - spect_widget->apply_peaks(fn_copy, peaks); - else - { - pthread_mutex_lock(&g_mutex); - delete[] g_current_file; - g_current_file = strdup(fn_copy); - delete g_peaks; - g_peaks = peaks; - pthread_mutex_unlock(&g_mutex); - } - free(fn_copy); - }, - Qt::QueuedConnection); - - free(filename); - return nullptr; -} - -static void load_track_async(const char * filename) -{ - pthread_mutex_lock(&g_mutex); - if (g_current_file && strcmp(filename, g_current_file) == 0) - { - pthread_mutex_unlock(&g_mutex); - return; - } - pthread_mutex_unlock(&g_mutex); - - char * fn_arg = strdup(filename); - pthread_t thread; - if (pthread_create(&thread, nullptr, worker_thread_func, fn_arg) == 0) - { - pthread_detach(thread); - } - else - { - free(fn_arg); - } -} - -static void on_playback_ready(void *, void *) -{ - String fn = aud_drct_get_filename(); - if (fn) - load_track_async((const char *)fn); -} - -static void * waveform_get_controls_widget() { return new WaveformControls(); } - -static const PreferencesWidget waveform_widgets[] = { - WidgetCustomQt(waveform_get_controls_widget)}; - -static const PluginPreferences waveform_prefs = {{waveform_widgets}}; - -class QtWaveform : public VisPlugin -{ -public: - static constexpr PluginInfo info = {N_("Waveform"), PACKAGE, nullptr, - &waveform_prefs, PluginQtOnly}; - - constexpr QtWaveform() : VisPlugin(info, Visualizer::Freq) {} - - bool init() override; - void cleanup() override; - void * get_qt_widget() override; - void clear() override; - void render_freq(const float * freq) override; -}; - -EXPORT QtWaveform aud_plugin_instance; - -bool QtWaveform::init() -{ - hook_associate("playback ready", (HookFunction)on_playback_ready, nullptr); - return true; -} - -void QtWaveform::cleanup() -{ - hook_dissociate("playback ready", (HookFunction)on_playback_ready); - pthread_mutex_lock(&g_mutex); - delete[] g_current_file; - g_current_file = nullptr; - delete g_peaks; - g_peaks = nullptr; - pthread_mutex_unlock(&g_mutex); -} - -void QtWaveform::render_freq(const float * freq) {} - -void QtWaveform::clear() -{ - if (spect_widget) - spect_widget->update(); -} - -void * QtWaveform::get_qt_widget() -{ - if (g_container) - return g_container; - - auto * container = new QWidget(); - auto * layout = new QVBoxLayout(container); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - auto * waveform = new WaveformWidget(); - layout->addWidget(waveform); - - g_container = container; - return container; -} diff --git a/src/waveform-qt/waveform_widget.cc b/src/waveform-qt/waveform_widget.cc new file mode 100644 index 000000000..210737a6e --- /dev/null +++ b/src/waveform-qt/waveform_widget.cc @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include "waveform_widget.h" + +#include + +#include +#include +#include +#include + +#include +#include + +#include "preload.h" +#include "track_peaks.h" +#include "track_state.h" + +static WaveformWidget * spect_widget = nullptr; +static QWidget * g_container = nullptr; + +WaveformWidget::WaveformWidget(QWidget * parent) : QWidget(parent) +{ + spect_widget = this; + + m_timer = new QTimer(this); + QObject::connect(m_timer, &QTimer::timeout, this, [this]() { update(); }); + m_timer->start(100); + + setMinimumHeight(80); + + if (aud_drct_get_ready()) + { + String fn = aud_drct_get_filename(); + if (fn) + load_track_async((const char *)fn); + } +} + +WaveformWidget::~WaveformWidget() +{ + spect_widget = nullptr; + g_container = nullptr; +} + +WaveformWidget * waveform_get_active_widget() { return spect_widget; } + +void WaveformWidget::apply_peaks(const char * filename, TrackPeaks * peaks) +{ + track_state_set(filename, peaks); + update(); +} + +void WaveformWidget::paint_background(QPainter & p) +{ + p.fillRect(0, 0, width(), height(), QColor(18, 18, 20)); +} + +void WaveformWidget::paint_waveform(QPainter & p) +{ + TrackPeaks local_peaks; + track_state_copy_into(&local_peaks); + + int w = width(), h = height(); + int center_y = h / 2; + + p.setPen(QColor(90, 90, 95)); + p.drawLine(0, center_y, w, center_y); + + if (local_peaks.num_buckets == 0) + return; + + double half = h / 2.0; + double scale = (half * 0.95) / (double)local_peaks.global_peak; + int n = local_peaks.num_buckets; + + p.setPen(Qt::NoPen); + + /* keep quiet bands faintly visible rather than pure black */ + const int floor_c = 40; + + for (int x = 0; x < w; x++) + { + int idx = x * n / w; + if (idx >= n) + idx = n - 1; + double top = center_y - local_peaks.maxs[idx] * scale; + double bot = center_y - local_peaks.mins[idx] * scale; + if (bot < top) + { + double tmp = top; + top = bot; + bot = tmp; + } + + /* low -> red, mid -> green, high -> blue, the same convention */ + int r = floor_c + (255 - floor_c) * local_peaks.low[idx] / 255; + int g = floor_c + (255 - floor_c) * local_peaks.mid[idx] / 255; + int b = floor_c + (255 - floor_c) * local_peaks.high[idx] / 255; + + p.setBrush(QColor(r, g, b)); + p.drawRect(QRectF(x, top, 1.0, bot - top + 1.0)); + } +} + +void WaveformWidget::paint_playhead(QPainter & p) +{ + if (!aud_drct_get_playing()) + return; + + int length = aud_drct_get_length(); + if (length <= 0) + return; + + int time = aud_drct_get_time(); + double frac = (double)time / length; + if (frac < 0) + frac = 0; + if (frac > 1) + frac = 1; + + double x = frac * width(); + + QPen pen(QColor(255, 40, 40)); + pen.setWidth(2); + p.setPen(pen); + p.drawLine(QPointF(x, 0), QPointF(x, height())); +} + +void WaveformWidget::resizeEvent(QResizeEvent *) { update(); } + +void WaveformWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + paint_background(p); + paint_waveform(p); + paint_playhead(p); +} + +void WaveformWidget::seek_to_x(int x) +{ + int length = aud_drct_get_length(); + if (length <= 0 || width() <= 0) + return; + + double frac = (double)x / width(); + if (frac < 0) + frac = 0; + if (frac > 1) + frac = 1; + + aud_drct_seek((int)(frac * length)); + update(); +} + +void WaveformWidget::mousePressEvent(QMouseEvent * event) +{ + if (event->button() == Qt::LeftButton) + seek_to_x(event->pos().x()); +} + +void WaveformWidget::mouseMoveEvent(QMouseEvent * event) +{ + if (event->buttons() & Qt::LeftButton) + seek_to_x(event->pos().x()); +} + +// async loader thread helper +static void * worker_thread_func(void * arg) +{ + char * filename = (char *)arg; + TrackPeaks * peaks = new TrackPeaks(); + + bool ok = preload_one_file(filename, peaks); + if (!ok) + { + delete peaks; + free(filename); + return nullptr; + } + + /* dynamic copy passed along safely to the Qt event loop lambda */ + char * fn_copy = strdup(filename); + QMetaObject::invokeMethod( + qApp, + [fn_copy, peaks]() { + if (spect_widget) + spect_widget->apply_peaks(fn_copy, peaks); + else + track_state_set(fn_copy, peaks); + free(fn_copy); + }, + Qt::QueuedConnection); + + free(filename); + return nullptr; +} + +void load_track_async(const char * filename) +{ + if (track_state_is_current(filename)) + return; + + char * fn_arg = strdup(filename); + pthread_t thread; + if (pthread_create(&thread, nullptr, worker_thread_func, fn_arg) == 0) + { + pthread_detach(thread); + } + else + { + free(fn_arg); + } +} + +QWidget * waveform_get_container_widget() +{ + if (g_container) + return g_container; + + auto * container = new QWidget(); + auto * layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto * waveform = new WaveformWidget(); + layout->addWidget(waveform); + + g_container = container; + return container; +} diff --git a/src/waveform-qt/waveform_widget.h b/src/waveform-qt/waveform_widget.h new file mode 100644 index 000000000..e529cbe40 --- /dev/null +++ b/src/waveform-qt/waveform_widget.h @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 0000xFFFF. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + * IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#ifndef __WAVEFORM_WIDGET_H__ +#define __WAVEFORM_WIDGET_H__ + +#include +#include + +struct TrackPeaks; + +class WaveformWidget : public QWidget +{ +public: + explicit WaveformWidget(QWidget * parent = nullptr); + ~WaveformWidget() override; + + /* Takes ownership of `peaks` and makes it the displayed envelope. */ + void apply_peaks(const char * filename, TrackPeaks * peaks); + +protected: + void resizeEvent(QResizeEvent *) override; + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent * event) override; + void mouseMoveEvent(QMouseEvent * event) override; + +private: + void paint_background(QPainter &); + void paint_waveform(QPainter &); + void paint_playhead(QPainter &); + void seek_to_x(int x); + + QTimer * m_timer; +}; + +/* Asynchronously loads (or decodes+caches) the peak envelope for + * `filename` and applies it to the active WaveformWidget, if any. */ +void load_track_async(const char * filename); + +/* The currently active WaveformWidget instance, or nullptr if none has + * been created (or it has since been destroyed by the host). */ +WaveformWidget * waveform_get_active_widget(); + +/* Returns the outer container widget that holds the waveform view, + * creating it on first call. Subsequent calls return the same + * instance until the host destroys it. */ +QWidget * waveform_get_container_widget(); + +#endif From 399c36b9f7bdb3539f18835b316beab814199e23 Mon Sep 17 00:00:00 2001 From: 0000xFFFF Date: Sun, 28 Jun 2026 19:13:51 +0200 Subject: [PATCH 7/7] forgot include --- src/waveform-qt/waveform_widget.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/src/waveform-qt/waveform_widget.cc b/src/waveform-qt/waveform_widget.cc index 210737a6e..8a1f3348d 100644 --- a/src/waveform-qt/waveform_widget.cc +++ b/src/waveform-qt/waveform_widget.cc @@ -22,6 +22,7 @@ #include +#include #include #include #include