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..55fe97487 --- /dev/null +++ b/src/waveform-qt/meson.build @@ -0,0 +1,17 @@ +avformat_dep = dependency('libavformat') +avcodec_dep = dependency('libavcodec') +avutil_dep = dependency('libavutil') +swresample_dep = dependency('libswresample') +threads_dep = dependency('threads') +shared_module('waveform-qt', + '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, + install_dir: visualization_plugin_dir +) 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_widget.cc b/src/waveform-qt/waveform_widget.cc new file mode 100644 index 000000000..8a1f3348d --- /dev/null +++ b/src/waveform-qt/waveform_widget.cc @@ -0,0 +1,250 @@ +/* + * 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 + +#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