diff --git a/approot/admin-debugtools.xml b/approot/admin-debugtools.xml
index 83025d9f..b7d380ea 100644
--- a/approot/admin-debugtools.xml
+++ b/approot/admin-debugtools.xml
@@ -8,8 +8,23 @@
${db}
+
+
+ ${pcm-decoding-stats}
+
+
+
+
diff --git a/approot/main.xml b/approot/main.xml
index 1fb31ea5..a3fb4c71 100644
--- a/approot/main.xml
+++ b/approot/main.xml
@@ -45,10 +45,8 @@
${scanner class="dropdown-item"}
${users class="dropdown-item"}
${about class="dropdown-item"}
- ${}
${debug-tools class="dropdown-item"}
- ${}
${}
diff --git a/approot/messages.xml b/approot/messages.xml
index 732e805b..146bfdbe 100644
--- a/approot/messages.xml
+++ b/approot/messages.xml
@@ -169,6 +169,13 @@
Export query profiling data
Database
+
+PCM decoding stats
+Run bench
+Download results
+Running…
+PCM decoding benchmark completed in {1}
+
Export traces
Tracing
diff --git a/approot/messages_es.xml b/approot/messages_es.xml
index c14b8491..21ba6ad6 100644
--- a/approot/messages_es.xml
+++ b/approot/messages_es.xml
@@ -169,6 +169,13 @@
Exportar datos de perfil de consultas
Base de datos
+
+Estadísticas de decodificación PCM
+Ejecutar benchmark
+Descargar resultados
+Ejecutando…
+Benchmark de decodificación PCM completado en {1}
+
Exportar las trazas
Trazas
diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml
index 97f392b4..44a7f588 100644
--- a/approot/messages_fr.xml
+++ b/approot/messages_fr.xml
@@ -169,6 +169,13 @@
Exporter les données de profilage des requêtes
Base de données
+
+Statistiques de décodage PCM
+Lancer le bench
+Télécharger les résultats
+En cours…
+Benchmark de décodage PCM terminé en {1}
+
Exporter les traces
Traces
diff --git a/approot/messages_it.xml b/approot/messages_it.xml
index da46dd5d..1ec58f23 100644
--- a/approot/messages_it.xml
+++ b/approot/messages_it.xml
@@ -169,6 +169,13 @@
Esporta i dati di profilazione delle query
Database
+
+Statistiche di decodifica PCM
+Avvia benchmark
+Scarica risultati
+In esecuzione…
+Benchmark di decodifica PCM completato in {1}
+
Esporta tracce
Tracing
diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml
index 16523ae9..f35671cd 100644
--- a/approot/messages_pl.xml
+++ b/approot/messages_pl.xml
@@ -186,6 +186,13 @@
Eksportuj dane profilowania zapytań
Baza danych
+
+Statystyki dekodowania PCM
+Uruchom benchmark
+Pobierz wyniki
+Trwa…
+Test wydajności dekodowania PCM zakończony w {1}
+
Eksportuj ślady
Śledzenie
diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml
index 2da4a425..c0bc9f44 100644
--- a/approot/messages_zh.xml
+++ b/approot/messages_zh.xml
@@ -167,6 +167,13 @@
导出查询分析数据
数据库
+
+PCM解码统计
+运行基准测试
+下载结果
+运行中…
+PCM解码基准测试已在{1}内完成
+
导出跟踪
跟踪
diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt
index 6ac4bc93..13ecb5b8 100644
--- a/src/lms/CMakeLists.txt
+++ b/src/lms/CMakeLists.txt
@@ -22,6 +22,8 @@ add_executable(lms
ui/Utils.cpp
ui/admin/AdminView.cpp
ui/admin/debug/Database.cpp
+ ui/admin/debug/PcmDecodingBenchmark.cpp
+ ui/admin/debug/PcmDecodingStats.cpp
ui/admin/debug/Tracing.cpp
ui/admin/About.cpp
ui/admin/DebugToolsView.cpp
diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp
index 36a27d66..a2523e88 100644
--- a/src/lms/ui/LmsApplication.cpp
+++ b/src/lms/ui/LmsApplication.cpp
@@ -40,7 +40,6 @@
#include "database/objects/Release.hpp"
#include "database/objects/TrackList.hpp"
#include "database/objects/User.hpp"
-#include "database/profiling/IQueryProfiler.hpp"
#include "services/artwork/IArtworkService.hpp"
#include "services/auth/IAuthTokenService.hpp"
#include "services/auth/IEnvService.hpp"
@@ -448,13 +447,7 @@ namespace lms::ui
navbar->bindNew("scan-settings", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/scan-settings" }, Wt::WString::tr("Lms.Admin.menu-scan-settings"));
navbar->bindNew("scanner", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/scanner" }, Wt::WString::tr("Lms.Admin.menu-scanner"));
navbar->bindNew("users", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/users" }, Wt::WString::tr("Lms.Admin.menu-users"));
- // Hide the entry if no debug service is enabled
- if (core::Service::get()
- || core::Service::get())
- {
- navbar->setCondition("if-debug-tools", true);
- navbar->bindNew("debug-tools", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/debug-tools" }, Wt::WString::tr("Lms.Admin.menu-debug-tools"));
- }
+ navbar->bindNew("debug-tools", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/debug-tools" }, Wt::WString::tr("Lms.Admin.menu-debug-tools"));
}
PathRouter* mainRouter{ main->bindNew("contents") };
diff --git a/src/lms/ui/admin/DebugToolsView.cpp b/src/lms/ui/admin/DebugToolsView.cpp
index 570c97fa..d83f8198 100644
--- a/src/lms/ui/admin/DebugToolsView.cpp
+++ b/src/lms/ui/admin/DebugToolsView.cpp
@@ -21,6 +21,7 @@
#include "admin/debug/Database.hpp"
#include "debug/Database.hpp"
+#include "debug/PcmDecodingStats.hpp"
#include "debug/Tracing.hpp"
namespace lms::ui
@@ -32,5 +33,6 @@ namespace lms::ui
bindNew("tracing");
bindNew("db");
+ bindNew("pcm-decoding-stats");
}
} // namespace lms::ui
diff --git a/src/lms/ui/admin/debug/PcmDecodingBenchmark.cpp b/src/lms/ui/admin/debug/PcmDecodingBenchmark.cpp
new file mode 100644
index 00000000..a34f0ac1
--- /dev/null
+++ b/src/lms/ui/admin/debug/PcmDecodingBenchmark.cpp
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#include "PcmDecodingBenchmark.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "audio/Exception.hpp"
+#include "audio/IPcmDecoder.hpp"
+#include "audio/PcmTypes.hpp"
+#include "core/String.hpp"
+#include "core/media/Codec.hpp"
+#include "database/IDb.hpp"
+#include "database/Session.hpp"
+#include "database/objects/Filters.hpp"
+#include "database/objects/Track.hpp"
+#include "database/objects/Types.hpp"
+
+#include "LmsApplication.hpp"
+
+namespace lms::ui
+{
+ namespace
+ {
+ using Clock = std::chrono::steady_clock;
+
+ struct DecodeResult
+ {
+ std::size_t fileSize{};
+ std::size_t decodedSampleCount{};
+ Clock::duration decodeDuration{};
+ };
+
+ constexpr std::chrono::milliseconds minTrackDuration{ std::chrono::minutes{ 3 } };
+ constexpr std::chrono::milliseconds maxTrackDuration{ std::chrono::minutes{ 5 } };
+ constexpr std::size_t maxTracksPerCodec{ 10 };
+ constexpr std::size_t queryBatchSize{ 100 };
+
+ struct TrackEntry
+ {
+ std::filesystem::path path;
+ std::chrono::milliseconds duration;
+ std::size_t fileSize{};
+ std::size_t bitrate{};
+ };
+
+ std::vector pickTracksForCodec(db::Session& session, core::media::Codec codec)
+ {
+ auto transaction{ session.createReadTransaction() };
+
+ db::Track::FindParameters params;
+ params.setFilters(db::Filters{}.setCodec(codec));
+ params.setSortMethod(db::TrackSortMethod::Random);
+ params.setRange(db::Range{ .offset = 0, .size = queryBatchSize });
+
+ const auto results{ db::Track::find(session, params) };
+
+ std::vector entries;
+ for (const db::Track::pointer& track : results.results)
+ {
+ const auto trackDuration{ track->getDuration() };
+ if (trackDuration >= minTrackDuration && trackDuration <= maxTrackDuration)
+ {
+ entries.push_back({ track->getAbsoluteFilePath(), trackDuration, static_cast(track->getFileSize()), track->getBitrate() });
+ if (entries.size() == maxTracksPerCodec)
+ break;
+ }
+ }
+
+ return entries;
+ }
+
+ DecodeResult decodeFile(const std::filesystem::path& path, std::size_t fileSize, const audio::PcmParameters& params)
+ {
+ constexpr std::size_t bufferSamples{ 4096 };
+ const std::size_t bufferSize{ bufferSamples * params.channelCount * audio::getSampleSize(params.sampleType) };
+
+ std::vector buffer(bufferSize);
+ std::array outputBuffers{ audio::IPcmDecoder::WritableBuffer{ buffer } };
+
+ const auto start{ Clock::now() };
+
+ auto decoder{ audio::createPcmDecoder(path, std::chrono::microseconds{ 0 }, params) };
+
+ std::size_t sampleCount{};
+ while (!decoder->finished())
+ sampleCount += decoder->readSamples(outputBuffers);
+
+ return DecodeResult{
+ .fileSize = fileSize,
+ .decodedSampleCount = sampleCount,
+ .decodeDuration = Clock::now() - start,
+ };
+ }
+
+ std::vector runBench(db::IDb& db)
+ {
+ constexpr audio::PcmParameters params{
+ .channelCount = 2,
+ .sampleRate = 48000,
+ .sampleType = audio::PcmSampleType::Signed16,
+ .byteOrder = std::endian::native,
+ .planar = false,
+ };
+
+ std::vector allResults;
+
+ core::media::visitCodecs([&](const core::media::CodecDesc& codecDesc) {
+ const std::vector tracks{ pickTracksForCodec(db.getTLSSession(), codecDesc.type) };
+ if (tracks.empty())
+ return;
+
+ std::vector trackResults;
+ for (const TrackEntry& entry : tracks)
+ {
+ try
+ {
+ const DecodeResult res{ decodeFile(entry.path, entry.fileSize, params) };
+ const float elapsedSeconds{ std::chrono::duration{ res.decodeDuration }.count() };
+ if (elapsedSeconds <= 0.F || res.decodedSampleCount == 0)
+ continue;
+
+ const float decodedAudioSeconds{ static_cast(res.decodedSampleCount) / params.sampleRate };
+ trackResults.push_back(PcmDecodingBenchmark::TrackDecodeResult{
+ .path = entry.path,
+ .bitrate = entry.bitrate,
+ .duration = entry.duration,
+ .realTimeFactor = decodedAudioSeconds / elapsedSeconds,
+ .speedKBs = (static_cast(res.fileSize) / 1024.F) / elapsedSeconds,
+ });
+ }
+ catch (const audio::Exception&)
+ {
+ }
+ }
+
+ if (trackResults.empty())
+ return;
+
+ std::vector rtFactors;
+ rtFactors.reserve(trackResults.size());
+ for (const auto& t : trackResults)
+ rtFactors.push_back(t.realTimeFactor);
+
+ const float mean{ std::accumulate(rtFactors.begin(), rtFactors.end(), 0.F) / static_cast(rtFactors.size()) };
+ const float variance{ std::accumulate(rtFactors.begin(), rtFactors.end(), 0.F, [mean](float acc, float v) {
+ return acc + (v - mean) * (v - mean);
+ }) / static_cast(rtFactors.size()) };
+
+ const auto [minIt, maxIt]{ std::minmax_element(rtFactors.begin(), rtFactors.end()) };
+ allResults.push_back(PcmDecodingBenchmark::CodecResult{
+ .codecName = std::string{ codecDesc.name.str() },
+ .tracks = std::move(trackResults),
+ .minRealTimeFactor = *minIt,
+ .maxRealTimeFactor = *maxIt,
+ .meanRealTimeFactor = mean,
+ .stdDevRealTimeFactor = std::sqrt(variance),
+ });
+ });
+
+ return allResults;
+ }
+ } // namespace
+
+ PcmDecodingBenchmark& PcmDecodingBenchmark::instance()
+ {
+ static PcmDecodingBenchmark s_instance;
+ return s_instance;
+ }
+
+ PcmDecodingBenchmark::State PcmDecodingBenchmark::getState() const
+ {
+ std::scoped_lock lock{ _mutex };
+ return _state;
+ }
+
+ void PcmDecodingBenchmark::start(db::IDb& db)
+ {
+ {
+ State prevState;
+ {
+ std::scoped_lock lock{ _mutex };
+
+ if (_state == State::Running)
+ return;
+
+ _reportFilename = "LMS_pcm_decoding_stats_" + core::stringUtils::toISO8601String(Wt::WDateTime::currentDateTime()) + ".txt";
+ prevState = _state;
+ _state = State::Running;
+ }
+
+ postStateToAllSessions(prevState, State::Running);
+ }
+
+ std::thread{ [this, &db] {
+ const auto start{ Clock::now() };
+ std::vector results{ runBench(db) };
+ const auto elapsed{ std::chrono::duration_cast(Clock::now() - start) };
+
+ State prevState;
+ {
+ std::scoped_lock lock{ _mutex };
+
+ _results = std::move(results);
+ _elapsed = elapsed;
+ prevState = _state;
+ _state = State::Completed;
+ }
+
+ postStateToAllSessions(prevState, State::Completed);
+ } }.detach();
+ }
+
+ void PcmDecodingBenchmark::registerOnStateChanged(const std::string& sessionId, std::function f)
+ {
+ std::scoped_lock lock{ _signalMutex };
+ _sessionSignals[sessionId].connect(std::move(f));
+ }
+
+ void PcmDecodingBenchmark::unregisterOnStateChanged(const std::string& sessionId)
+ {
+ std::scoped_lock lock{ _signalMutex };
+ _sessionSignals.erase(sessionId);
+ }
+
+ void PcmDecodingBenchmark::postStateToAllSessions(State oldState, State newState)
+ {
+ Wt::WServer::instance()->postAll([this, oldState, newState] {
+ if (!LmsApp)
+ return;
+
+ Wt::Signal* sig{ nullptr };
+ {
+ std::scoped_lock lock{ _signalMutex };
+ auto it{ _sessionSignals.find(wApp->sessionId()) };
+ if (it != _sessionSignals.end())
+ sig = &it->second;
+ }
+
+ if (sig)
+ {
+ sig->emit(oldState, newState);
+ LmsApp->triggerUpdate();
+ }
+ });
+ }
+} // namespace lms::ui
diff --git a/src/lms/ui/admin/debug/PcmDecodingBenchmark.hpp b/src/lms/ui/admin/debug/PcmDecodingBenchmark.hpp
new file mode 100644
index 00000000..3a18e238
--- /dev/null
+++ b/src/lms/ui/admin/debug/PcmDecodingBenchmark.hpp
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace lms::db
+{
+ class IDb;
+}
+
+namespace lms::ui
+{
+ class PcmDecodingBenchmark
+ {
+ public:
+ enum class State { Idle, Running, Completed };
+
+ struct TrackDecodeResult
+ {
+ std::filesystem::path path;
+ std::size_t bitrate{};
+ std::chrono::milliseconds duration;
+ float realTimeFactor{};
+ float speedKBs{};
+ };
+
+ struct CodecResult
+ {
+ std::string codecName;
+ std::vector tracks;
+ float minRealTimeFactor{};
+ float maxRealTimeFactor{};
+ float meanRealTimeFactor{};
+ float stdDevRealTimeFactor{};
+ };
+
+ static PcmDecodingBenchmark& instance();
+
+ State getState() const;
+ std::span getResults() const { return _results; }
+ std::chrono::milliseconds getElapsed() const { return _elapsed; }
+ std::string_view getReportFilename() const { return _reportFilename; }
+
+ void start(db::IDb& db);
+
+ void registerOnStateChanged(const std::string& sessionId, std::function f);
+ void unregisterOnStateChanged(const std::string& sessionId);
+
+ private:
+ void postStateToAllSessions(State oldState, State newState);
+
+ State _state{ State::Idle };
+ mutable std::mutex _mutex;
+ std::vector _results;
+ std::chrono::milliseconds _elapsed{};
+ std::string _reportFilename;
+
+ std::mutex _signalMutex;
+ std::unordered_map> _sessionSignals;
+ };
+} // namespace lms::ui
diff --git a/src/lms/ui/admin/debug/PcmDecodingStats.cpp b/src/lms/ui/admin/debug/PcmDecodingStats.cpp
new file mode 100644
index 00000000..6c13eb29
--- /dev/null
+++ b/src/lms/ui/admin/debug/PcmDecodingStats.cpp
@@ -0,0 +1,200 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#include "PcmDecodingStats.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "LmsApplication.hpp"
+#include "Notification.hpp"
+#include "PcmDecodingBenchmark.hpp"
+
+namespace lms::ui
+{
+ namespace
+ {
+ class PcmDecodingStatsResource : public Wt::WResource
+ {
+ public:
+ PcmDecodingStatsResource(std::span results, std::chrono::milliseconds elapsed, std::string filename)
+ : _results{ results.begin(), results.end() }
+ , _elapsed{ elapsed }
+ , _filename{ std::move(filename) }
+ {
+ }
+
+ ~PcmDecodingStatsResource() override
+ {
+ beingDeleted();
+ }
+
+ PcmDecodingStatsResource(const PcmDecodingStatsResource&) = delete;
+ PcmDecodingStatsResource& operator=(const PcmDecodingStatsResource&) = delete;
+
+ private:
+ void handleRequest(const Wt::Http::Request&, Wt::Http::Response& response) override
+ {
+ response.setMimeType("text/plain; charset=utf-8");
+
+ auto encodeHttpHeaderField = [](const std::string& fieldName, const std::string& fieldValue) {
+ return fieldName + "*=UTF-8''" + Wt::Utils::urlEncode(fieldValue);
+ };
+
+ const std::string cdp{ encodeHttpHeaderField("filename", _filename) };
+ response.addHeader("Content-Disposition", "attachment; " + cdp);
+
+ response.out() << "PCM decoding bench (elapsed: " << std::chrono::duration_cast(_elapsed).count() << "s)\n\n";
+
+ std::vector allRtFactors;
+ for (const PcmDecodingBenchmark::CodecResult& r : _results)
+ {
+ response.out() << "=== " << r.codecName << " (" << r.tracks.size() << " track(s)) ===\n";
+
+ for (const PcmDecodingBenchmark::TrackDecodeResult& t : r.tracks)
+ {
+ const long durationSec{ static_cast(t.duration.count() / 1000) };
+ response.out() << " " << t.path
+ << " bitrate=" << t.bitrate / 1000 << " kbps"
+ << " duration=" << durationSec / 60 << "m" << durationSec % 60 << "s"
+ << " real_time_factor=" << static_cast(t.realTimeFactor) << "x"
+ << " speed=" << static_cast(t.speedKBs) << " KB/s\n";
+ allRtFactors.push_back(t.realTimeFactor);
+ }
+
+ response.out() << " real_time_factor summary:"
+ << " min=" << static_cast(r.minRealTimeFactor) << "x"
+ << " max=" << static_cast(r.maxRealTimeFactor) << "x"
+ << " mean=" << static_cast(r.meanRealTimeFactor) << "x"
+ << " stddev=" << static_cast(r.stdDevRealTimeFactor) << "x\n\n";
+ }
+
+ if (!allRtFactors.empty())
+ {
+ const float mean{ std::accumulate(allRtFactors.begin(), allRtFactors.end(), 0.F) / static_cast(allRtFactors.size()) };
+ const float variance{ std::accumulate(allRtFactors.begin(), allRtFactors.end(), 0.F, [mean](float acc, float v) {
+ return acc + (v - mean) * (v - mean);
+ }) / static_cast(allRtFactors.size()) };
+
+ response.out() << "=== OVERALL (" << allRtFactors.size() << " track(s)) ===\n";
+ const auto [minIt, maxIt]{ std::minmax_element(allRtFactors.begin(), allRtFactors.end()) };
+ response.out() << " real_time_factor summary:"
+ << " min=" << static_cast(*minIt) << "x"
+ << " max=" << static_cast(*maxIt) << "x"
+ << " mean=" << static_cast(mean) << "x"
+ << " stddev=" << static_cast(std::sqrtf(variance)) << "x\n";
+ }
+ }
+
+ std::vector _results;
+ std::chrono::milliseconds _elapsed{};
+ std::string _filename;
+ };
+ } // namespace
+
+ PcmDecodingStats::PcmDecodingStats()
+ : Wt::WTemplate{ Wt::WString::tr("Lms.Admin.DebugTools.PcmDecodingStats.template") }
+ , _db{ LmsApp->getDb() }
+ {
+ addFunction("tr", &Wt::WTemplate::Functions::tr);
+
+ _runBtn = bindNew("run-btn", Wt::WString::tr("Lms.Admin.DebugTools.PcmDecodingStats.run"));
+ _downloadBtn = bindNew("download-btn");
+ _downloadBtn->setText(Wt::WString::tr("Lms.Admin.DebugTools.PcmDecodingStats.download"));
+ _statusText = bindNew("status");
+
+ _runBtn->clicked().connect(this, &PcmDecodingStats::onRunClicked);
+
+ processState(PcmDecodingBenchmark::instance().getState());
+
+ PcmDecodingBenchmark::instance().registerOnStateChanged(wApp->sessionId(), [this](PcmDecodingBenchmark::State oldState, PcmDecodingBenchmark::State newState) {
+ onStateChanged(oldState, newState);
+ });
+ }
+
+ PcmDecodingStats::~PcmDecodingStats()
+ {
+ PcmDecodingBenchmark::instance().unregisterOnStateChanged(wApp->sessionId());
+ }
+
+ void PcmDecodingStats::onRunClicked()
+ {
+ PcmDecodingBenchmark::instance().start(_db);
+ }
+
+ void PcmDecodingStats::processState(PcmDecodingBenchmark::State state)
+ {
+ switch (state)
+ {
+ case PcmDecodingBenchmark::State::Idle:
+ _runBtn->setEnabled(true);
+ _downloadBtn->hide();
+ _statusText->setText({});
+ break;
+ case PcmDecodingBenchmark::State::Running:
+ _runBtn->setEnabled(false);
+ _downloadBtn->hide();
+ _statusText->setText(Wt::WString::tr("Lms.Admin.DebugTools.PcmDecodingStats.running"));
+ break;
+ case PcmDecodingBenchmark::State::Completed:
+ _runBtn->setEnabled(true);
+ _statusText->setText({});
+ setupDownloadButton();
+ break;
+ }
+ }
+
+ void PcmDecodingStats::onStateChanged(PcmDecodingBenchmark::State oldState, PcmDecodingBenchmark::State newState)
+ {
+ processState(newState);
+
+ if (oldState == PcmDecodingBenchmark::State::Running && newState == PcmDecodingBenchmark::State::Completed)
+ {
+ std::ostringstream oss;
+ oss << std::chrono::duration_cast(PcmDecodingBenchmark::instance().getElapsed()).count() << "s";
+ LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.Admin.DebugTools.PcmDecodingStats.benchmark-completed").arg(oss.str()));
+ }
+ }
+
+ void PcmDecodingStats::setupDownloadButton()
+ {
+ auto& bench{ PcmDecodingBenchmark::instance() };
+ auto resource{ std::make_shared(bench.getResults(), bench.getElapsed(), std::string{ bench.getReportFilename() } ) };
+
+ Wt::WLink link{ resource };
+ link.setTarget(Wt::LinkTarget::NewWindow);
+ _downloadBtn->setLink(link);
+ _downloadBtn->show();
+ }
+} // namespace lms::ui
diff --git a/src/lms/ui/admin/debug/PcmDecodingStats.hpp b/src/lms/ui/admin/debug/PcmDecodingStats.hpp
new file mode 100644
index 00000000..a894fe15
--- /dev/null
+++ b/src/lms/ui/admin/debug/PcmDecodingStats.hpp
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include "PcmDecodingBenchmark.hpp"
+
+namespace lms::db
+{
+ class IDb;
+}
+
+namespace lms::ui
+{
+ class PcmDecodingStats : public Wt::WTemplate
+ {
+ public:
+ PcmDecodingStats();
+ ~PcmDecodingStats() override;
+
+ private:
+ void onRunClicked();
+ void onStateChanged(PcmDecodingBenchmark::State oldState, PcmDecodingBenchmark::State newState);
+ void processState(PcmDecodingBenchmark::State state);
+ void setupDownloadButton();
+
+ db::IDb& _db;
+ Wt::WPushButton* _runBtn{};
+ Wt::WAnchor* _downloadBtn{};
+ Wt::WText* _statusText{};
+ };
+} // namespace lms::ui