Restored recommendations based on acoustic similarities (using musicnn), fixes #301

This commit is contained in:
emeric
2026-06-02 08:32:43 +02:00
parent 1524106124
commit eb7f65878f
227 changed files with 10324 additions and 4673 deletions
+2 -3
View File
@@ -1,8 +1,7 @@
# Want as many meaningful warnings as possible
add_compile_options(-Wall -Wextra -pedantic)
add_subdirectory(libs)
add_subdirectory(lms)
add_subdirectory(tools)
+1 -1
View File
@@ -2,6 +2,6 @@ add_subdirectory(audio)
add_subdirectory(core)
add_subdirectory(database)
add_subdirectory(image)
add_subdirectory(math)
add_subdirectory(services)
add_subdirectory(som)
add_subdirectory(subsonic)
+30
View File
@@ -2,6 +2,7 @@ pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat libswre
pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib)
pkg_check_modules(PulseAudio IMPORTED_TARGET libpulse)
pkg_check_modules(ALSA IMPORTED_TARGET alsa)
pkg_check_modules(OnnxRuntime IMPORTED_TARGET libonnxruntime)
if (PulseAudio_FOUND OR ALSA_FOUND)
message(STATUS "Audio output available (PulseAudio=${PulseAudio_FOUND}, ALSA=${ALSA_FOUND})")
@@ -10,6 +11,7 @@ else()
endif()
add_library(lmsaudio STATIC
impl/features/MelFilterBank.cpp
impl/ffmpeg/AudioFile.cpp
impl/ffmpeg/AudioFileInfo.cpp
impl/ffmpeg/AudioFileInfoParser.cpp
@@ -19,6 +21,7 @@ add_library(lmsaudio STATIC
impl/ffmpeg/TagReader.cpp
impl/ffmpeg/Transcoder.cpp
impl/ffmpeg/Utils.cpp
impl/musicnn/MusicNNEmbeddings.cpp
impl/taglib/AudioFileInfo.cpp
impl/taglib/AudioFileInfoParser.cpp
impl/taglib/ImageReader.cpp
@@ -27,6 +30,7 @@ add_library(lmsaudio STATIC
impl/utils/PcmDecodeStreamer.cpp
impl/AudioFileInfoParser.cpp
impl/AudioOutput.cpp
impl/MusicNNEmbeddingExtractorCreator.cpp
impl/PcmTypes.cpp
impl/TagReader.cpp
)
@@ -37,6 +41,7 @@ target_include_directories(lmsaudio INTERFACE
target_include_directories(lmsaudio PRIVATE
include
impl
${AVCODEC_INCLUDE_DIR}
${AVFORMAT_INCLUDE_DIR}
${AVUTIL_INCLUDE_DIR}
@@ -47,6 +52,7 @@ target_link_libraries(lmsaudio PUBLIC
)
target_link_libraries(lmsaudio PRIVATE
lmsmath
PkgConfig::LIBAV
PkgConfig::Taglib
)
@@ -54,6 +60,12 @@ target_link_libraries(lmsaudio PRIVATE
target_compile_definitions(lmsaudio PRIVATE
$<$<BOOL:${PulseAudio_FOUND}>:LMS_HAVE_PULSEAUDIO>
$<$<BOOL:${ALSA_FOUND}>:LMS_HAVE_ALSA>
$<$<BOOL:${OnnxRuntime_FOUND}>:LMS_HAVE_ONNX_RUNTIME>
)
# Should be safe enough for what we're doing
target_compile_options(lmsaudio PRIVATE
$<$<NOT:$<CONFIG:Debug>>:-ffast-math>
)
if (PulseAudio_FOUND)
@@ -80,3 +92,21 @@ if (ALSA_FOUND)
)
endif()
if (OnnxRuntime_FOUND)
target_sources(lmsaudio PRIVATE
impl/musicnn/MusicNNEmbeddingExtractor.cpp
impl/musicnn/MusicNNModel.cpp
)
target_link_libraries(lmsaudio PRIVATE PkgConfig::OnnxRuntime)
message(STATUS "Using ONNX Runtime (${OnnxRuntime_VERSION})")
install(DIRECTORY models DESTINATION share/lms)
endif()
if(BUILD_TESTING)
add_subdirectory(test)
endif()
if (BUILD_BENCHMARKS)
add_subdirectory(bench)
endif()
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -17,10 +17,6 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include <benchmark/benchmark.h>
int main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
BENCHMARK_MAIN();
+18
View File
@@ -0,0 +1,18 @@
add_executable(bench-audio
Audio.cpp
)
if (OnnxRuntime_FOUND)
target_sources(bench-audio PRIVATE
MusicNNModel.cpp
)
endif()
target_include_directories(bench-audio PRIVATE
../impl
)
target_link_libraries(bench-audio PRIVATE
lmsaudio
benchmark
)
+68
View File
@@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <array>
#include <cstdlib>
#include <filesystem>
#include <random>
#include <benchmark/benchmark.h>
#include "musicnn/MusicNNModel.hpp"
namespace lms::audio::musicnn::benchmarks
{
namespace
{
std::filesystem::path getMusicNNModelPathFromEnv()
{
const char* p{ std::getenv("LMS_MUSICNN_MODEL") };
return p ? std::filesystem::path{ p } : std::filesystem::path{};
}
std::array<float, MusicNNModel::inputFrames * MusicNNModel::inputBands> makeRandomPatch()
{
std::minstd_rand rng{ 42 };
std::uniform_real_distribution<float> dist{ 0.F, 1.F };
std::array<float, MusicNNModel::inputFrames * MusicNNModel::inputBands> patch{};
for (float& v : patch)
v = dist(rng);
return patch;
}
} // namespace
static void BM_MusicNNModel_forward(benchmark::State& state)
{
const std::filesystem::path path{ getMusicNNModelPathFromEnv() };
if (path.empty())
{
state.SkipWithMessage("LMS_MUSICNN_MODEL not set");
return;
}
const MusicNNModel model{ path };
const auto patch{ makeRandomPatch() };
for (auto _ : state)
benchmark::DoNotOptimize(model.forward(patch));
}
BENCHMARK(BM_MusicNNModel_forward);
} // namespace lms::audio::musicnn::benchmarks
@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include <array>
#include <fstream>
#include <span>
#include <string>
#include "core/XxHash3.hpp"
#if LMS_HAVE_ONNX_RUNTIME
#include "musicnn/MusicNNEmbeddingExtractor.hpp"
#endif
namespace lms::audio
{
bool canExtractMusicNNEmbeddings()
{
#if LMS_HAVE_ONNX_RUNTIME
return true;
#else
return false;
#endif
}
std::unique_ptr<IMusicNNEmbeddingExtractor> createMusicNNEmbeddingExtractor([[maybe_unused]] const std::filesystem::path& modelPath, std::size_t maxPatchCount)
{
#if LMS_HAVE_ONNX_RUNTIME
return std::make_unique<musicnn::MusicNNEmbeddingExtractor>(modelPath, maxPatchCount);
#else
return {};
#endif
}
std::string getMusicNNModelIdentifier(const std::filesystem::path& modelPath)
{
std::ifstream file{ modelPath, std::ios::binary };
if (!file)
return {};
core::XxHash3_64 hasher;
constexpr std::size_t readBufSize{ 65536 };
std::array<char, readBufSize> buf{};
while (file.read(buf.data(), buf.size()) || file.gcount() > 0)
hasher.update(std::as_bytes(std::span{ buf.data(), static_cast<std::size_t>(file.gcount()) }));
if (!file.eof())
return {};
return std::to_string(hasher.digest());
}
} // namespace lms::audio
+7
View File
@@ -38,4 +38,11 @@ namespace lms::audio
throw Exception{ "Unhandled sample type" };
}
namespace helpers
{
std::size_t sampleCountToByteCount(std::size_t sampleCount, PcmSampleType sampleType, unsigned channelCount)
{
return sampleCount * audio::getSampleSize(sampleType) * channelCount;
}
} // namespace helpers
} // namespace lms::audio
@@ -0,0 +1,143 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include "MelFilterBank.hpp"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <numeric>
#include "audio/Exception.hpp"
namespace lms::audio::features
{
float freqToMel(float freq)
{
return 2595.F * std::log10(1.0F + freq / 700.F);
}
float melToFreq(float mel)
{
return 700.F * (std::pow(10.F, mel / 2595.F) - 1.F);
}
MelFilterBank::MelFilterBank(std::vector<Filter>&& _filters, std::size_t binCount)
: _filters{ std::move(_filters) }
, _binCount{ binCount }
{
}
const MelFilterBank::Filter& MelFilterBank::getFilter(std::size_t m) const
{
return _filters.at(m);
}
std::size_t MelFilterBank::getFilterCount() const
{
return _filters.size();
}
std::size_t MelFilterBank::getBinCount() const
{
return _binCount;
}
float MelFilterBank::computeEnergy(std::size_t m, std::span<const float> input) const
{
if (input.size() != _binCount)
throw Exception{ "Input size must be equal to the number of bins" };
const auto& filter{ _filters.at(m) };
assert(filter.leftBinIndex + filter.weights.size() <= input.size());
float energy{};
std::size_t bin{ filter.leftBinIndex };
for (std::size_t i{}, n = filter.weights.size(); i < n; ++i, ++bin)
energy += input[bin] * filter.weights[i];
return energy;
}
MelFilterBank computeMelFilterBank(std::size_t nfft, std::size_t sampleRate, std::size_t filterCount, float fMin, float fMax)
{
const float nyquist{ sampleRate / 2.F };
const float effectiveFMin{ (fMin <= 0.F) ? 0.F : fMin };
const float effectiveFMax{ (fMax <= 0.F || fMax > nyquist) ? nyquist : fMax };
const float melMin{ freqToMel(effectiveFMin) };
const float melMax{ freqToMel(effectiveFMax) };
// 1. mel points
std::vector<float> melPoints(filterCount + 2);
for (std::size_t i{}; i < melPoints.size(); ++i)
melPoints[i] = melMin + i * (melMax - melMin) / (filterCount + 1);
// 2. mel -> Hz
std::vector<float> freqs(filterCount + 2);
std::transform(melPoints.begin(), melPoints.end(), freqs.begin(), melToFreq);
// 3. Hz -> bins
std::vector<size_t> bins(filterCount + 2);
for (std::size_t i{}; i < bins.size(); ++i)
bins[i] = static_cast<size_t>(std::floor(nfft * freqs[i] / sampleRate));
// 4. fix duplicates
for (std::size_t i{ 1 }; i < bins.size(); ++i)
{
if (bins[i] <= bins[i - 1])
bins[i] = bins[i - 1] + 1;
}
// 5. build filters
const std::size_t binCount{ nfft / 2 + 1 };
std::vector<MelFilterBank::Filter> filters{ filterCount };
for (std::size_t m{}; m < filterCount; ++m)
{
const std::size_t left{ bins[m] };
const std::size_t center{ bins[m + 1] };
const std::size_t right{ bins[m + 2] };
std::vector<float> filterWeights;
filterWeights.reserve(right - left);
// rising edge of the triangle
for (std::size_t k{ left }; k < center; ++k)
filterWeights.push_back(float(k - left) / (center - left));
// falling edge of the triangle
for (std::size_t k{ center }; k < right; ++k)
filterWeights.push_back(float(right - k) / (right - center));
assert(filterWeights.size() == right - left);
// normalize the filter to sum = 1
const float sum{ std::accumulate(filterWeights.begin(), filterWeights.end(), 0.F) };
if (sum > 0.F)
{
for (float& weight : filterWeights)
weight /= sum;
}
filters[m] = MelFilterBank::Filter{ std::move(filterWeights), left };
}
return MelFilterBank{ std::move(filters), binCount };
}
} // namespace lms::audio::features
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <span>
#include <vector>
namespace lms::audio::features
{
float freqToMel(float freq);
float melToFreq(float mel);
struct MelFilterBank
{
struct Filter
{
std::vector<float> weights; // normalized so that the sum of weights equals 1.0
std::size_t leftBinIndex; // index of the leftmost FFT bin covered by the filter
};
// binCount is the number of FFT bins (nfft/2 + 1) that the filters can cover
MelFilterBank(std::vector<Filter>&& _filters, std::size_t binCount);
const Filter& getFilter(std::size_t m) const;
std::size_t getFilterCount() const;
std::size_t getBinCount() const;
float computeEnergy(std::size_t m, std::span<const float> input) const;
private:
const std::vector<Filter> _filters;
const std::size_t _binCount;
};
// Each filter covers a range of FFT bins and is normalized so that the sum of its weights equals 1.0
// Each filter stores only its non-zero triangular region (sparse representation)
// fMin/fMax: frequency range in Hz. Defaults (0.f, 0.f) span from 0 to Nyquist.
MelFilterBank computeMelFilterBank(size_t nfft, size_t sampleRate, size_t filterCount, float fMin = 0.F, float fMax = 0.F);
} // namespace lms::audio::features
+2 -2
View File
@@ -285,7 +285,7 @@ namespace lms::audio::ffmpeg
bool AudioFile::hasAttachedPictures() const
{
for (std::size_t i = 0; i < _context->nb_streams; ++i)
for (std::size_t i{}; i < _context->nb_streams; ++i)
{
if (_context->streams[i]->disposition & AV_DISPOSITION_ATTACHED_PIC)
return true;
@@ -304,7 +304,7 @@ namespace lms::audio::ffmpeg
{ AV_CODEC_ID_PPM, "image/x-portable-pixmap" },
};
for (std::size_t i = 0; i < _context->nb_streams; ++i)
for (std::size_t i{}; i < _context->nb_streams; ++i)
{
AVStream* avstream = _context->streams[i];
+15 -2
View File
@@ -127,6 +127,14 @@ namespace lms::audio::ffmpeg
}
}
{
_estimatedDuration = std::chrono::milliseconds{ _context->duration == AV_NOPTS_VALUE ? 0 : _context->duration / AV_TIME_BASE * 1'000 };
if (_estimatedDuration > offset)
_estimatedDuration = _estimatedDuration - std::chrono::duration_cast<std::chrono::milliseconds>(offset);
else
_estimatedDuration = {};
}
_decoderContext = AVCodecContextPtr{ ::avcodec_alloc_context3(decoder) };
if (!_decoderContext)
throw Exception{ "Cannot allocate decoder context" };
@@ -225,7 +233,7 @@ namespace lms::audio::ffmpeg
else
{
std::array<uint8_t*, AV_NUM_DATA_POINTERS> outData{};
for (size_t i = 0; i < outputChannelBuffers.size(); ++i)
for (std::size_t i{}; i < outputChannelBuffers.size(); ++i)
outData[i] = reinterpret_cast<uint8_t*>(outputChannelBuffers[i].data());
// Resample decoded audio
@@ -266,6 +274,11 @@ namespace lms::audio::ffmpeg
return _finished;
}
std::chrono::milliseconds PcmDecoder::getEstimatedDuration() const
{
return _estimatedDuration;
}
std::size_t PcmDecoder::computeSampleCountPerChannel(std::span<WritableBuffer> outputChannelBuffers) const
{
if (_parameters.planar)
@@ -343,7 +356,7 @@ namespace lms::audio::ffmpeg
std::size_t PcmDecoder::drainResampler(std::span<WritableBuffer> outputChannelBuffers, std::size_t maxSamplesPerChannel)
{
std::array<uint8_t*, AV_NUM_DATA_POINTERS> outData{};
for (size_t i = 0; i < outputChannelBuffers.size(); ++i)
for (std::size_t i{}; i < outputChannelBuffers.size(); ++i)
outData[i] = reinterpret_cast<uint8_t*>(outputChannelBuffers[i].data());
const int outSampleCount{ ::swr_convert(_resampleContext.get(),
@@ -40,6 +40,8 @@ namespace lms::audio::ffmpeg
std::size_t readSamples(std::span<WritableBuffer> outputChannelBuffers) override;
bool finished() const override;
std::chrono::milliseconds getEstimatedDuration() const override;
std::size_t computeSampleCountPerChannel(std::span<WritableBuffer> outputChannelBuffers) const;
void feedDecoder();
std::size_t drainResampler(std::span<WritableBuffer> outputChannelBuffers, std::size_t maxSamplesPerChannel);
@@ -52,6 +54,7 @@ namespace lms::audio::ffmpeg
bool _draining{};
AVFormatContextPtr _context;
std::chrono::milliseconds _estimatedDuration{};
int _inputStreamIndex{};
AVCodecContextPtr _decoderContext;
AVFramePtr _decodedFrame;
+1 -1
View File
@@ -62,7 +62,7 @@ namespace lms::audio::ffmpeg::utils
void avLogCallback(void*, int level, const char* fmt, va_list vl)
{
if (!core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
if (!core::Service<core::logging::ILogger>::get() || core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
return;
if (level > AV_LOG_WARNING)
@@ -0,0 +1,168 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include "MusicNNEmbeddingExtractor.hpp"
#include <algorithm>
#include <array>
#include <cassert>
#include <cmath>
#include <numeric>
#include "audio/Exception.hpp"
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include "math/StatsAccumulator.hpp"
#include "musicnn/MusicNNModel.hpp"
namespace lms::audio::musicnn
{
namespace
{
constexpr float minMeaningfulPatchRms{ 0.003F };
template<typename FloatType>
FloatType computeRms(std::span<const FloatType> samples)
{
const FloatType sumSq{ std::transform_reduce(samples.begin(), samples.end(), FloatType{}, std::plus<>{}, [](FloatType s) { return s * s; }) };
return std::sqrt(sumSq / static_cast<FloatType>(samples.size()));
}
// We want something like this:
// gap patch(0) gap patch(1) gap patch(maxPatchCount) gap
std::size_t computePatchGap(std::size_t totalFrameCount, std::size_t patchFrameCount, std::size_t maxPatchCount)
{
assert(maxPatchCount > 0);
assert(patchFrameCount > 0);
const std::size_t patchCount{ std::min(maxPatchCount, totalFrameCount / patchFrameCount) };
if (patchCount == 0)
return 0;
return (totalFrameCount - patchCount * patchFrameCount) / (patchCount + 1);
}
} // namespace
// Accumulates one 187-frame MusicNN mel patch
class MusicNNEmbeddingExtractor::PatchAccumulator
{
public:
void addMelRow(std::span<const float, melBandCount> melRow, float frameRms)
{
assert(_frameCount < patchFrameCount);
const std::size_t offset{ _frameCount * melBandCount };
std::copy(melRow.begin(), melRow.end(), _melMatrix.begin() + static_cast<std::ptrdiff_t>(offset));
_rmsAccum += frameRms;
++_frameCount;
}
void reset() noexcept
{
_frameCount = {};
_rmsAccum = {};
}
[[nodiscard]] bool complete() const { return _frameCount == patchFrameCount; }
[[nodiscard]] bool meaningful() const
{
return (_frameCount > 0) && ((_rmsAccum / static_cast<float>(_frameCount)) >= minMeaningfulPatchRms);
}
[[nodiscard]] std::span<const float, patchFrameCount * melBandCount> data() const
{
return _melMatrix;
}
private:
std::size_t _frameCount{};
float _rmsAccum{};
std::array<float, patchFrameCount * melBandCount> _melMatrix;
};
MusicNNEmbeddingExtractor::MusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount)
: _melFilterBank{ features::computeMelFilterBank(fftSize, sampleRate, melBandCount, melFMin, melFMax) }
, _model{ modelPath }
, _maxPatchCount{ maxPatchCount }
{
static_assert(MusicNNEmbeddingExtractor::windowSize == MusicNNEmbeddingExtractor::fftSize);
if (_maxPatchCount <= 0)
throw audio::Exception{ "MusicNN embedding extractor: max patch count must be > 0" };
}
IMusicNNEmbeddingExtractor::ExtractionResult MusicNNEmbeddingExtractor::extract(const std::filesystem::path& audioFile) const
{
auto frameDecoder{ std::make_unique<FrameDecoder>(audioFile,
PcmParameters{ .channelCount = 1,
.sampleRate = static_cast<unsigned>(sampleRate),
.sampleType = PcmSampleType::Float32,
.byteOrder = std::endian::native,
.planar = false },
frameHopSamples) };
std::array<float, melBandCount> logMelRow{};
std::array<math::StatsAccumulator<float>, decltype(_model)::outputSize> embeddingAccumulators;
ExtractionResult result;
const std::size_t estimatedFrameCount{ frameDecoder->getEstimatedFrameCount() };
// Fallback: use a gap of two patch lengths if the frame count is unknown
const std::size_t patchGapFrameCount{ estimatedFrameCount ? computePatchGap(frameDecoder->getEstimatedFrameCount(), patchFrameCount, _maxPatchCount) : (2 * patchFrameCount) };
const auto patchAccumulator{ std::make_unique<PatchAccumulator>() };
while (true)
{
patchAccumulator->reset();
const auto onFrame{ [&](const FrameDecoder::SpectralFrameView& frame) {
// MusicNN log compression: log10(10000 * mel + 1)
for (std::size_t m{}; m < melBandCount; ++m)
{
const float energy{ _melFilterBank.computeEnergy(m, std::span<const float>(frame.powerSpectrum)) };
logMelRow[m] = std::log10(10000.F * energy + 1.F);
}
const float rms{ computeRms(frame.rawSamples.subspan(0, frameHopSamples)) };
patchAccumulator->addMelRow(logMelRow, rms);
} };
if (patchGapFrameCount > 0 && frameDecoder->skipFrames(patchGapFrameCount) == 0)
break;
if (frameDecoder->decodeFrames(patchFrameCount, onFrame) < patchFrameCount)
break;
assert(patchAccumulator->complete());
if (!patchAccumulator->meaningful())
continue;
const auto embedding{ _model.forward(patchAccumulator->data()) };
for (std::size_t d{}; d < embedding.size(); ++d)
embeddingAccumulators[d].add(embedding[d]);
++result.patchCount;
}
if (result.patchCount > 0)
{
for (std::size_t d{}; d < decltype(_model)::outputSize; ++d)
result.embeddings.mean.values[d] = embeddingAccumulators[d].getMean();
}
return result;
}
} // namespace lms::audio::musicnn
@@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include "MusicNNModel.hpp"
#include "features/MelFilterBank.hpp"
#include "utils/PcmSpectralFrameDecoder.hpp"
namespace lms::audio::musicnn
{
class MusicNNEmbeddingExtractor : public IMusicNNEmbeddingExtractor
{
public:
MusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount);
~MusicNNEmbeddingExtractor() override = default;
MusicNNEmbeddingExtractor(const MusicNNEmbeddingExtractor&) = delete;
MusicNNEmbeddingExtractor& operator=(const MusicNNEmbeddingExtractor&) = delete;
private:
[[nodiscard]] ExtractionResult extract(const std::filesystem::path& audioFile) const override;
// MusicNN signal processing constants (from musicnn/configuration.py and musicnn_torch.py)
static constexpr std::size_t sampleRate{ 16'000 };
static constexpr std::size_t windowSize{ 512 }; // 512-sample Hann window (32 ms)
static constexpr std::size_t fftSize{ 512 };
static constexpr std::size_t frameHopSamples{ 256 }; // 16 ms hop (matches FFT_HOP in musicnn)
static constexpr std::size_t melBandCount{ 96 };
static constexpr float melFMin{ 0.F };
static constexpr float melFMax{ 8'000.F };
static constexpr std::size_t patchFrameCount{ MusicNNModel::inputFrames }; // 187 frames = 3 s
class PatchAccumulator;
using FrameDecoder = PcmSpectralFrameDecoder<512, float>;
const features::MelFilterBank _melFilterBank;
const MusicNNModel _model;
const std::size_t _maxPatchCount;
};
} // namespace lms::audio::musicnn
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include "audio/MusicNNEmbeddings.hpp"
#include <bit>
#include <cstring>
#include <limits>
#include "audio/Exception.hpp"
namespace lms::audio
{
static_assert(sizeof(float) == 4);
static_assert(std::numeric_limits<float>::is_iec559);
namespace
{
constexpr uint32_t byteswap32(uint32_t x)
{
return (x >> 24) | ((x >> 8) & 0x0000FF00u) | ((x << 8) & 0x00FF0000u) | (x << 24);
}
void writeFloats(std::span<const float> data, std::span<std::byte> blob)
{
if (blob.size() < data.size() * sizeof(uint32_t))
throw Exception{ "Buffer too small to write MusicNN embeddings" };
for (std::size_t i{}; i < data.size(); ++i)
{
uint32_t bits{ std::bit_cast<uint32_t>(data[i]) };
if constexpr (std::endian::native == std::endian::little)
bits = byteswap32(bits);
std::memcpy(blob.data() + i * 4, &bits, 4);
}
}
void readFloats(std::span<const std::byte> blob, std::span<float> data)
{
if (blob.size() < data.size() * sizeof(uint32_t))
throw Exception{ "Buffer too small to read MusicNN embeddings" };
for (std::size_t i{}; i < data.size(); ++i)
{
uint32_t bits{};
std::memcpy(&bits, blob.data() + i * 4, 4);
if constexpr (std::endian::native == std::endian::little)
bits = byteswap32(bits);
data[i] = std::bit_cast<float>(bits);
}
}
} // anonymous namespace
void trackMusicNNEmbeddingsToBlob(const TrackMusicNNEmbeddings& embeddings, std::span<std::byte> buffer)
{
if (buffer.size() < sizeof(TrackMusicNNEmbeddings))
throw Exception{ "Buffer too small to write TrackMusicNNEmbeddings" };
writeFloats(embeddings.mean.values, buffer.subspan(0, MusicNNEmbedding::size * sizeof(float)));
}
void trackMusicNNEmbeddingsFromBlob(std::span<const std::byte> buffer, TrackMusicNNEmbeddings& embeddings)
{
if (buffer.size() < sizeof(TrackMusicNNEmbeddings))
throw Exception{ "Buffer too small to read TrackMusicNNEmbeddings" };
readFloats(buffer.subspan(0, MusicNNEmbedding::size * sizeof(float)), embeddings.mean.values);
}
} // namespace lms::audio
@@ -0,0 +1,111 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include "MusicNNModel.hpp"
#include <string>
#include <onnxruntime_cxx_api.h>
#include "audio/Exception.hpp"
namespace lms::audio::musicnn
{
namespace
{
// Shape of the ONNX model's single input tensor: [batch=1, T=187, mel=96]
const std::array<std::int64_t, 3> inputShape{ 1,
static_cast<std::int64_t>(MusicNNModel::inputFrames),
static_cast<std::int64_t>(MusicNNModel::inputBands) };
// Shape of the ONNX model's single output tensor: [batch=1, embedding=200]
const std::array<std::int64_t, 2> outputShape{ 1,
static_cast<std::int64_t>(MusicNNModel::outputSize) };
constexpr const char* inputName{ "mel_patch" };
constexpr const char* outputName{ "embedding" };
} // namespace
struct MusicNNModel::Impl
{
Ort::Env env;
Ort::SessionOptions sessionOptions;
Ort::Session session;
Ort::MemoryInfo memoryInfo;
explicit Impl(const std::filesystem::path& onnxPath)
: env{ ORT_LOGGING_LEVEL_ERROR, "MusicNN" }
, session{ [&]() -> Ort::Session {
sessionOptions.SetIntraOpNumThreads(1);
sessionOptions.SetInterOpNumThreads(1);
return Ort::Session{ env, onnxPath.c_str(), sessionOptions };
}() }
, memoryInfo{ Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault) }
{
}
};
MusicNNModel::MusicNNModel(const std::filesystem::path& onnxPath)
{
try
{
_impl = std::make_unique<Impl>(onnxPath);
}
catch (const Ort::Exception& e)
{
throw audio::Exception{ std::string{ "Failed to load ONNX model '" } + onnxPath.string() + "': " + e.what() };
}
}
MusicNNModel::~MusicNNModel() = default;
std::array<float, MusicNNModel::outputSize> MusicNNModel::forward(
std::span<const float, inputFrames * inputBands> melPatch) const
{
Ort::Value inputTensor{ Ort::Value::CreateTensor<float>(
_impl->memoryInfo,
const_cast<float*>(melPatch.data()), // safe cast
melPatch.size(),
inputShape.data(),
inputShape.size()) };
std::array<float, outputSize> result{};
Ort::Value outputTensor{ Ort::Value::CreateTensor<float>(
_impl->memoryInfo,
result.data(),
result.size(),
outputShape.data(),
outputShape.size()) };
try
{
auto inputNames{ std::to_array({ inputName }) };
auto outputNames{ std::to_array({ outputName }) };
_impl->session.Run(Ort::RunOptions{ nullptr },
inputNames.data(), &inputTensor, 1,
outputNames.data(), &outputTensor, 1);
}
catch (const Ort::Exception& e)
{
throw audio::Exception{ std::string{ "ONNX inference failed: " } + e.what() };
}
return result;
}
} // namespace lms::audio::musicnn
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <array>
#include <cstddef>
#include <filesystem>
#include <memory>
#include <span>
namespace lms::audio::musicnn
{
// The ONNX model must be exported with:
// input "mel_patch" shape [1, 1, 187, 96]
// output "embedding" shape [1, 200]
//
// Export script: tools/musicnn/export_onnx.py
class MusicNNModel
{
public:
explicit MusicNNModel(const std::filesystem::path& onnxPath);
~MusicNNModel();
MusicNNModel(const MusicNNModel&) = delete;
MusicNNModel& operator=(const MusicNNModel&) = delete;
static inline constexpr std::size_t inputFrames{ 187 };
static inline constexpr std::size_t inputBands{ 96 };
static inline constexpr std::size_t outputSize{ 200 };
[[nodiscard]] std::array<float, outputSize> forward(std::span<const float, inputFrames * inputBands> melPatch) const;
private:
// Pimpl: keep ORT headers out of translation units that include this header.
struct Impl;
std::unique_ptr<Impl> _impl;
};
} // namespace lms::audio::musicnn
@@ -20,6 +20,7 @@
#pragma once
#include <cstddef>
#include <span>
#include <vector>
#include <boost/asio/io_context.hpp>
@@ -0,0 +1,222 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <algorithm>
#include <array>
#include <bit>
#include <cassert>
#include <complex>
#include <concepts>
#include <cstddef>
#include <filesystem>
#include <functional>
#include <memory>
#include <vector>
#include "core/AlignedHeapArray.hpp"
#include "audio/IPcmDecoder.hpp"
#include "audio/PcmTypes.hpp"
#include "math/FFT.hpp"
#include "math/Window.hpp"
namespace lms::audio
{
// Stateful PCM frame decoder that applies a Hann window + FFT per frame.
template<std::size_t WindowSize, typename FloatType = float>
class PcmSpectralFrameDecoder
{
static_assert(std::has_single_bit(WindowSize), "WindowSize must be a power of two");
public:
using FFTPlan = math::FixedRealFFTPlan<WindowSize, FloatType>;
static constexpr std::size_t spectrumSize{ FFTPlan::getOutputSize() };
PcmSpectralFrameDecoder(const std::filesystem::path& audioFile, const PcmParameters& params, std::size_t hopSize)
: PcmSpectralFrameDecoder{ createPcmDecoder(audioFile, {}, params), hopSize }
{
}
~PcmSpectralFrameDecoder() = default;
explicit PcmSpectralFrameDecoder(std::unique_ptr<IPcmDecoder> decoder, std::size_t hopSize)
: _pcmParams{ decoder->getParameters() }
, _hopSize{ hopSize }
, _powerScale{ FloatType{ 1 } / (_window.energy() * static_cast<FloatType>(WindowSize)) }
, _decoder{ std::move(decoder) }
, _samplesBuffer(bufferFrameCount * _hopSize + WindowSize)
, _bufferedSampleCount{ WindowSize / 2 } // first analysis frame centered on sample 0, matching librosa center=True semantics.
{
assert(_hopSize > 0);
}
PcmSpectralFrameDecoder(const PcmSpectralFrameDecoder&) = delete;
PcmSpectralFrameDecoder& operator=(const PcmSpectralFrameDecoder&) = delete;
std::size_t hopSize() const noexcept { return _hopSize; }
const PcmParameters& pcmParameters() const noexcept { return _pcmParams; }
std::size_t getEstimatedFrameCount() const
{
const auto duration{ _decoder->getEstimatedDuration() };
if (duration <= std::chrono::milliseconds::zero())
return 0;
const auto totalSamples{ static_cast<std::size_t>((static_cast<std::uint64_t>(duration.count()) * _pcmParams.sampleRate) / 1'000) };
constexpr std::size_t halfWindow{ WindowSize / 2 };
if (totalSamples < halfWindow) // Not enough samples to produce even the first frame.
return 0;
return 1 + ((totalSamples - halfWindow) / _hopSize);
}
// Spectral data for a single frame.
struct SpectralFrameView
{
std::span<const FloatType, WindowSize> rawSamples;
std::span<const FloatType, spectrumSize> powerSpectrum;
};
// Decodes up to frameCount frames, invoking callback for each. May return fewer than
// frameCount at EOF. Returns 0 only if no frame at all could be decoded.
template<typename Callback>
requires std::invocable<Callback, const SpectralFrameView&>
std::size_t decodeFrames(std::size_t frameCount, Callback&& callback)
{
if (frameCount == 0)
return 0;
std::size_t decodedCount{};
while (decodedCount < frameCount)
{
if (!readAtLeastSamples(std::max(WindowSize, _hopSize)))
break;
const std::span<const FloatType, WindowSize> rawSamples{ _samplesBuffer.data(), WindowSize };
const std::span<FloatType, WindowSize> windowedFrame{ _windowedFrame.data(), WindowSize };
_window.apply(rawSamples, windowedFrame);
_fftPlan.apply(_windowedFrame, _fftOutput);
// Reuse _windowedFrame for power spectrum (spectrumSize <= WindowSize).
const std::span<FloatType, spectrumSize> powerBuffer{ _windowedFrame.data(), spectrumSize };
std::transform(_fftOutput.cbegin(), _fftOutput.cend(), powerBuffer.begin(),
[this](const std::complex<FloatType>& bin) {
return (bin.real() * bin.real() + bin.imag() * bin.imag()) * _powerScale;
});
const SpectralFrameView frame{ .rawSamples = rawSamples,
.powerSpectrum = std::span<const FloatType, spectrumSize>{ _windowedFrame.data(), spectrumSize } };
std::invoke(callback, frame);
consumeSamples(_hopSize);
++_currentFrameIndex;
++decodedCount;
}
return decodedCount;
}
// Skips exactly the next frameCount frames without computing FFT or invoking callbacks.
// Returns frameCount on success, 0 on EOF.
std::size_t skipFrames(std::size_t frameCount)
{
if (frameCount == 0)
return 0;
std::size_t skippedFrameCount{};
while (skippedFrameCount < frameCount)
{
if (!readAtLeastSamples(std::max(WindowSize, _hopSize)))
break;
consumeSamples(_hopSize);
++_currentFrameIndex;
++skippedFrameCount;
}
return skippedFrameCount;
}
[[nodiscard]] std::size_t currentFrameIndex() const noexcept { return _currentFrameIndex; }
private:
static constexpr std::size_t bufferFrameCount{ 20 };
bool readAtLeastSamples(std::size_t sampleCount)
{
if (sampleCount <= _bufferedSampleCount)
return true;
if (_samplesBuffer.size() < sampleCount)
_samplesBuffer.resize(sampleCount);
while ((_bufferedSampleCount < sampleCount) && !_endOfStream)
{
std::span<FloatType> dest{ _samplesBuffer.data() + _bufferedSampleCount, _samplesBuffer.size() - _bufferedSampleCount };
assert(!dest.empty());
if (dest.empty())
break;
std::array outputBuffers{ IPcmDecoder::WritableBuffer{ std::as_writable_bytes(dest) } };
const std::size_t samplesRead{ _decoder->readSamples(outputBuffers) };
if (samplesRead == 0)
{
_endOfStream = true;
break;
}
_bufferedSampleCount += samplesRead;
}
return _bufferedSampleCount >= sampleCount;
}
void consumeSamples(std::size_t samplesToDrop)
{
// TODO use a circular buffer and only compacts at the end of the buffer
assert(samplesToDrop <= _bufferedSampleCount);
const auto remaining{ _bufferedSampleCount - samplesToDrop };
if (remaining)
{
std::move(_samplesBuffer.begin() + samplesToDrop,
_samplesBuffer.begin() + _bufferedSampleCount,
_samplesBuffer.begin());
}
_bufferedSampleCount = remaining;
}
const PcmParameters _pcmParams;
const std::size_t _hopSize;
const math::HannWindow<WindowSize, FloatType> _window;
const FloatType _powerScale;
const FFTPlan _fftPlan{};
std::unique_ptr<IPcmDecoder> _decoder;
std::vector<FloatType> _samplesBuffer;
core::AlignedHeapArray<FloatType, FFTPlan::minBufferAlignment> _windowedFrame{ FFTPlan::getInputSize() };
core::AlignedHeapArray<std::complex<FloatType>, FFTPlan::minBufferAlignment> _fftOutput{ FFTPlan::getOutputSize() };
std::size_t _bufferedSampleCount{};
std::size_t _currentFrameIndex{};
bool _endOfStream{};
};
} // namespace lms::audio
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include <memory>
#include "audio/MusicNNEmbeddings.hpp"
namespace lms::audio
{
class IMusicNNEmbeddingExtractor
{
public:
virtual ~IMusicNNEmbeddingExtractor() = default;
struct ExtractionResult
{
TrackMusicNNEmbeddings embeddings{};
std::size_t patchCount{};
};
[[nodiscard]] virtual ExtractionResult extract(const std::filesystem::path& audioFile) const = 0;
};
bool canExtractMusicNNEmbeddings();
std::unique_ptr<IMusicNNEmbeddingExtractor> createMusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount);
std::string getMusicNNModelIdentifier(const std::filesystem::path& modelPath);
} // namespace lms::audio
@@ -43,8 +43,11 @@ namespace lms::audio
// Each buffer must be sized to hold an integer number of samples according to the requested sample type.
// For example, for Float32 planar output, each buffer size must be divisible by sizeof(float).
// The decoder will use the buffer sizes to determine the maximum number of samples it can write.
// The decoder will not try to fill in the whole supplied buffer
virtual std::size_t readSamples(std::span<WritableBuffer> outputChannelBuffers) = 0;
virtual bool finished() const = 0;
virtual std::chrono::milliseconds getEstimatedDuration() const = 0; // initial offset is taken into account, 0 if unknown
};
// Throw on error
@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <array>
#include <span>
namespace lms::audio
{
struct MusicNNEmbedding
{
static inline constexpr std::size_t size{ 200 };
std::array<float, size> values;
};
struct TrackMusicNNEmbeddings
{
MusicNNEmbedding mean;
};
// Buffer size must be at least sizeof(TrackMusicNNEmbeddings)
void trackMusicNNEmbeddingsToBlob(const TrackMusicNNEmbeddings& embeddings, std::span<std::byte> buffer);
void trackMusicNNEmbeddingsFromBlob(std::span<const std::byte> buffer, TrackMusicNNEmbeddings& embeddings);
} // namespace lms::audio
+18
View File
@@ -20,6 +20,7 @@
#pragma once
#include <bit>
#include <chrono>
namespace lms::audio
{
@@ -41,4 +42,21 @@ namespace lms::audio
std::endian byteOrder;
bool planar;
};
namespace helpers
{
template<typename Rep, typename Period>
std::size_t durationToSampleCount(std::chrono::duration<Rep, Period> duration, unsigned sampleRate)
{
return static_cast<std::size_t>(duration.count() * sampleRate * Period::num / Period::den);
}
template<typename Duration>
Duration sampleCountToDuration(std::size_t sampleCount, unsigned sampleRate)
{
return std::chrono::duration_cast<Duration>(std::chrono::duration<double>(sampleCount) / sampleRate);
}
std::size_t sampleCountToByteCount(std::size_t sampleCount, PcmSampleType sampleType, unsigned channelCount);
} // namespace helpers
} // namespace lms::audio
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
include(GoogleTest)
add_executable(test-audio
MelFilterBank.cpp
MusicNNEmbeddings.cpp
PcmSpectralFrameDecoder.cpp
)
if (OnnxRuntime_FOUND)
target_sources(test-audio PRIVATE
MusicNNModel.cpp
)
endif()
target_include_directories(test-audio PRIVATE
../impl
)
target_link_libraries(test-audio PRIVATE
lmsaudio
lmsmath
GTest::GTest
GTest::gtest_main
)
target_compile_options(test-audio PRIVATE
$<$<NOT:$<CONFIG:Debug>>:-ffast-math>
)
if (NOT CMAKE_CROSSCOMPILING)
gtest_discover_tests(test-audio)
endif()
+203
View File
@@ -0,0 +1,203 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <algorithm>
#include <limits>
#include <numeric>
#include <gtest/gtest.h>
#include "audio/Exception.hpp"
#include "features/MelFilterBank.hpp"
namespace lms::audio::features::tests
{
constexpr float epsilon{ 1e-5F };
constexpr std::size_t NFFT{ 2048 };
constexpr std::size_t sampleRate{ 22050 };
constexpr std::size_t filterCount{ 40 };
TEST(MelFilterBank, sizeCheck)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
EXPECT_EQ(bank.getFilterCount(), filterCount);
EXPECT_EQ(bank.getBinCount(), NFFT / 2 + 1);
}
TEST(MelFilterBank, differentSampleRates)
{
for (const std::size_t sr : { std::size_t{ 8000 }, std::size_t{ 16000 }, std::size_t{ 44100 }, std::size_t{ 48000 } })
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sr, filterCount) };
EXPECT_EQ(bank.getFilterCount(), filterCount) << "sr=" << sr;
}
}
TEST(MelFilterBank, nonNegativeWeights)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const auto& filter = bank.getFilter(m);
for (float w : filter.weights)
EXPECT_GE(w, 0.F) << "Filter " << m << " has negative weight: " << w;
}
}
TEST(MelFilterBank, peaksAreNonZero)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const auto& filter{ bank.getFilter(m) };
const float maxVal{ *std::max_element(filter.weights.begin(), filter.weights.end()) };
EXPECT_GT(maxVal, 0.F) << "Filter " << m << " has zero peak value";
}
}
TEST(MelFilterBank, filtersAreUnitSum)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const auto& filter{ bank.getFilter(m) };
const float sum{ std::accumulate(filter.weights.begin(), filter.weights.end(), 0.F) };
EXPECT_NEAR(sum, 1.F, epsilon) << "Filter " << m << " sum = " << sum;
}
}
TEST(MelFilterBank, everyBinCovered)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
const std::size_t binCount{ bank.getBinCount() };
std::vector<bool> covered(binCount, false);
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const auto& filter{ bank.getFilter(m) };
std::size_t bin{ filter.leftBinIndex };
for (float w : filter.weights)
{
if (w > 0.F)
covered[bin] = true;
++bin;
}
}
// Find actual covered range
auto first{ std::find(covered.begin(), covered.end(), true) };
auto last{ std::find(covered.rbegin(), covered.rend(), true).base() };
ASSERT_NE(first, covered.end()); // sanity
for (auto it = first; it != last; ++it)
{
EXPECT_TRUE(*it) << "A bin in the covered range is not covered by any filter";
}
}
TEST(MelFilterBank, overlapAtMostTwo)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
const std::size_t binCount{ bank.getBinCount() };
std::vector<std::size_t> overlap(binCount, 0);
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const auto& filter{ bank.getFilter(m) };
std::size_t bin{ filter.leftBinIndex };
for (float w : filter.weights)
{
if (w > 0.F)
overlap[bin]++;
++bin;
}
}
for (std::size_t k{}; k < binCount; ++k)
{
EXPECT_LE(overlap[k], 2) << "Bin " << k << " is covered by " << overlap[k] << " filters";
}
}
TEST(MelFilterBank, flatSpectrumEnergySanity)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
std::vector<float> flatSpectrum(bank.getBinCount(), 1.F);
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const float energy{ bank.computeEnergy(m, flatSpectrum) };
EXPECT_GT(energy, 0.F) << "Filter " << m << " has zero energy for flat spectrum";
}
}
TEST(MelFilterBank, zeroFilterCount)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, 0) };
EXPECT_EQ(bank.getFilterCount(), 0U);
EXPECT_EQ(bank.getBinCount(), NFFT / 2 + 1);
}
TEST(MelFilterBank, computeEnergyRejectsInvalidInputSize)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
std::vector<float> invalidInput(bank.getBinCount() - 1, 1.F);
EXPECT_THROW(bank.computeEnergy(0, invalidInput), Exception);
}
TEST(MelFilterBank, zeroSpectrum)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
std::vector<float> zeroSpectrum(bank.getBinCount(), 0.F);
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const float energy{ bank.computeEnergy(m, zeroSpectrum) };
EXPECT_FLOAT_EQ(energy, 0.F) << "Filter " << m << " should have zero energy for zero spectrum";
}
}
TEST(MelFilterBank, largeSpectrumValues)
{
const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) };
// Weights sum to 1.0 per filter, so energy = input * 1.0, no overflow risk at max/2
const float largeValue{ std::numeric_limits<float>::max() / 2.F };
std::vector<float> largeSpectrum(bank.getBinCount(), largeValue);
for (std::size_t m{}; m < bank.getFilterCount(); ++m)
{
const float energy{ bank.computeEnergy(m, largeSpectrum) };
EXPECT_GT(energy, 0.F) << "Filter " << m << " energy is not positive for large spectrum";
}
}
} // namespace lms::audio::features::tests
+59
View File
@@ -0,0 +1,59 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <numeric>
#include <vector>
#include <gtest/gtest.h>
#include "audio/Exception.hpp"
#include "audio/MusicNNEmbeddings.hpp"
namespace lms::audio::tests
{
TEST(MusicNNEmbeddings, blobRoundTrip)
{
TrackMusicNNEmbeddings original{};
std::iota(original.mean.values.begin(), original.mean.values.end(), 0.F);
std::vector<std::byte> blob(sizeof(TrackMusicNNEmbeddings));
trackMusicNNEmbeddingsToBlob(original, blob);
TrackMusicNNEmbeddings restored{};
trackMusicNNEmbeddingsFromBlob(blob, restored);
for (std::size_t i{}; i < MusicNNEmbedding::size; ++i)
EXPECT_FLOAT_EQ(restored.mean.values[i], original.mean.values[i]);
}
TEST(MusicNNEmbeddings, blobSizeTooSmallThrows)
{
TrackMusicNNEmbeddings embeddings{};
std::vector<std::byte> blob(sizeof(TrackMusicNNEmbeddings) - 1);
EXPECT_THROW(trackMusicNNEmbeddingsToBlob(embeddings, blob), Exception);
}
TEST(MusicNNEmbeddings, blobFromSizeTooSmallThrows)
{
std::vector<std::byte> blob(sizeof(TrackMusicNNEmbeddings) - 1, std::byte{});
TrackMusicNNEmbeddings embeddings{};
EXPECT_THROW(trackMusicNNEmbeddingsFromBlob(blob, embeddings), Exception);
}
} // namespace lms::audio::tests
+76
View File
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <array>
#include <cstdlib>
#include <filesystem>
#include <random>
#include <gtest/gtest.h>
#include "musicnn/MusicNNModel.hpp"
namespace lms::audio::musicnn::tests
{
namespace
{
std::filesystem::path getMusicNNModelPathFromEnv()
{
const char* p{ std::getenv("LMS_MUSICNN_MODEL") };
return p ? std::filesystem::path{ p } : std::filesystem::path{};
}
std::array<float, MusicNNModel::inputFrames * MusicNNModel::inputBands> makeRandomPatch()
{
std::minstd_rand rng{ 42 };
std::uniform_real_distribution<float> dist{ 0.F, 1.F };
std::array<float, MusicNNModel::inputFrames * MusicNNModel::inputBands> patch{};
for (float& v : patch)
v = dist(rng);
return patch;
}
} // namespace
TEST(MusicNNModel, CanConstruct)
{
const std::filesystem::path path{ getMusicNNModelPathFromEnv() };
if (path.empty())
GTEST_SKIP() << "LMS_MUSICNN_MODEL not set";
EXPECT_NO_THROW({ const MusicNNModel model{ path }; });
}
TEST(MusicNNModel, CanForward)
{
const std::filesystem::path path{ getMusicNNModelPathFromEnv() };
if (path.empty())
GTEST_SKIP() << "LMS_MUSICNN_MODEL not set";
const MusicNNModel model{ path };
const auto patch{ makeRandomPatch() };
EXPECT_NO_THROW({ [[maybe_unused]] const auto output{ model.forward(patch) }; });
}
} // namespace lms::audio::musicnn::tests
@@ -0,0 +1,201 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <cstring>
#include <memory>
#include <random>
#include <span>
#include <vector>
#include <gtest/gtest.h>
#include "audio/IPcmDecoder.hpp"
#include "audio/PcmTypes.hpp"
#include "utils/PcmSpectralFrameDecoder.hpp"
namespace lms::audio::tests
{
namespace
{
// A mock IPcmDecoder that emits samples 0, 1, 2, 3, ... (as float) up to totalSamples,
class SequencePcmDecoder : public IPcmDecoder
{
public:
SequencePcmDecoder(std::size_t totalSampleCount)
: _totalSampleCount{ totalSampleCount }
{
}
const PcmParameters& getParameters() const override { return _params; }
std::size_t readSamples(std::span<WritableBuffer> outputChannelBuffers) override
{
assert(outputChannelBuffers.size() == 1); // only planar
if (_finished)
return 0;
auto& buf{ outputChannelBuffers[0] };
if (buf.size() == 0)
return 0;
assert(buf.size() % sizeof(float) == 0);
// not always writing up to what is requested
std::uniform_int_distribution dist{ std::size_t{ 1 }, buf.size() / sizeof(float) };
std::size_t sampleCountToWrite{ dist(_randomEngine) };
if (_currentSampleIndex + sampleCountToWrite > _totalSampleCount)
{
sampleCountToWrite = _totalSampleCount - _currentSampleIndex;
_finished = true;
}
float* dest{ reinterpret_cast<float*>(buf.data()) };
for (std::size_t i{}; i < sampleCountToWrite; ++i)
*(dest++) = static_cast<float>(_currentSampleIndex++);
return sampleCountToWrite;
}
bool finished() const override { return _finished; }
std::chrono::milliseconds getEstimatedDuration() const override
{
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::duration<float>{ static_cast<float>(_totalSampleCount) / static_cast<float>(_params.sampleRate) });
}
private:
const PcmParameters _params{
.channelCount = 1,
.sampleRate = 16000,
.sampleType = PcmSampleType::Float32,
.byteOrder = std::endian::native,
.planar = false,
};
std::minstd_rand _randomEngine{ 42 }; // fixed seed for reproducibility
std::size_t _totalSampleCount{};
std::size_t _currentSampleIndex{};
bool _finished{};
};
template<typename T, std::size_t N>
void expectSpanEq(std::span<const T, N> actual, const std::array<T, N>& expected)
{
for (std::size_t i{}; i < N; ++i)
EXPECT_FLOAT_EQ(actual[i], expected[i]) << "index=" << i;
}
constexpr std::size_t WindowSize{ 8 };
constexpr std::size_t HopSize{ 4 };
using FrameDecoder = PcmSpectralFrameDecoder<WindowSize, float>;
} // namespace
TEST(SequencePcmDecoder, basic)
{
constexpr std::size_t decoderTotalSampleCount{ 32 };
SequencePcmDecoder decoder{ decoderTotalSampleCount };
std::size_t totalSampleReadCount{};
while (true)
{
std::array<float, 16> buffer{};
std::array outputBuffers{ IPcmDecoder::WritableBuffer{ std::as_writable_bytes(std::span{ buffer }) } };
const std::size_t sampleReadCount{ decoder.readSamples(outputBuffers) };
if (sampleReadCount == 0)
break;
for (std::size_t i{}; i < sampleReadCount; ++i)
EXPECT_FLOAT_EQ(buffer[i], totalSampleReadCount + i);
totalSampleReadCount += sampleReadCount;
}
EXPECT_EQ(totalSampleReadCount, decoderTotalSampleCount);
}
TEST(PcmSpectralFrameDecoder, firstFrameIsCenteredOnSample0)
{
FrameDecoder frameDecoder{ std::make_unique<SequencePcmDecoder>(32), HopSize };
using Frame = std::array<float, WindowSize>;
std::vector<Frame> frames;
EXPECT_EQ(frameDecoder.currentFrameIndex(), 0);
const std::size_t decoded{ frameDecoder.decodeFrames(1,
[&](const FrameDecoder::SpectralFrameView& frame) {
auto& newFrame{ frames.emplace_back() };
std::copy(std::cbegin(frame.rawSamples), std::cend(frame.rawSamples), std::begin(newFrame));
}) };
ASSERT_EQ(decoded, 1);
ASSERT_EQ(frames.size(), 1);
EXPECT_EQ(frameDecoder.currentFrameIndex(), 1);
expectSpanEq<float, WindowSize>(frames[0], { 0.F, 0.F, 0.F, 0.F, 0.F, 1.F, 2.F, 3.F });
}
TEST(PcmSpectralFrameDecoder, framesAdvanceByHopSize)
{
FrameDecoder frameDecoder{ std::make_unique<SequencePcmDecoder>(16), HopSize };
using Frame = std::array<float, WindowSize>;
std::vector<Frame> frames;
const std::size_t decoded{ frameDecoder.decodeFrames(2,
[&](const FrameDecoder::SpectralFrameView& frame) {
auto& newFrame{ frames.emplace_back() };
std::copy(std::cbegin(frame.rawSamples), std::cend(frame.rawSamples), std::begin(newFrame));
}) };
ASSERT_EQ(decoded, 2);
ASSERT_EQ(frames.size(), 2);
EXPECT_EQ(frameDecoder.currentFrameIndex(), 2);
expectSpanEq<float, WindowSize>(
frames[0],
{ 0.F, 0.F, 0.F, 0.F, 0.F, 1.F, 2.F, 3.F });
expectSpanEq<float, WindowSize>(
frames[1],
{ 0.F, 1.F, 2.F, 3.F, 4.F, 5.F, 6.F, 7.F });
}
TEST(PcmSpectralFrameDecoder, skipFramesAdvancesState)
{
FrameDecoder frameDecoder{ std::make_unique<SequencePcmDecoder>(32), HopSize };
using Frame = std::array<float, WindowSize>;
std::vector<Frame> frames;
EXPECT_EQ(frameDecoder.currentFrameIndex(), 0);
ASSERT_EQ(frameDecoder.skipFrames(2), 2);
EXPECT_EQ(frameDecoder.currentFrameIndex(), 2);
Frame lastFrame{};
ASSERT_EQ(frameDecoder.decodeFrames(1,
[&](const FrameDecoder::SpectralFrameView& frame) {
std::copy(std::cbegin(frame.rawSamples), std::cend(frame.rawSamples), std::begin(lastFrame));
}),
1);
expectSpanEq<float, WindowSize>(
lastFrame,
{ 4.F, 5.F, 6.F, 7.F, 8.F, 9.F, 10.F, 11.F });
EXPECT_EQ(frameDecoder.currentFrameIndex(), 3);
}
} // namespace lms::audio::tests
+1 -1
View File
@@ -1,5 +1,5 @@
pkg_check_modules(Config++ REQUIRED IMPORTED_TARGET libconfig++)
pkg_check_modules(Archive REQUIRED IMPORTED_TARGET libarchive)
pkg_check_modules(Config++ REQUIRED IMPORTED_TARGET libconfig++)
pkg_check_modules(XXHASH REQUIRED IMPORTED_TARGET libxxhash)
set(LMS_VERSION ${PROJECT_VERSION})
+1
View File
@@ -1,5 +1,6 @@
add_executable(bench-core
Core.cpp
TraceLoggerBench.cpp
)
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -17,10 +17,6 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include <benchmark/benchmark.h>
int main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
BENCHMARK_MAIN();
+2 -5
View File
@@ -24,7 +24,7 @@
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
namespace lms::core
namespace lms::core::benchs
{
// The trace logger is meant to built/destroyed once
const Service<logging::ILogger> logger{ logging::createLogger() };
@@ -73,7 +73,4 @@ namespace lms::core
BENCHMARK(BM_TraceLogger_Overview_withArg)->Threads(1)->Threads(std::thread::hardware_concurrency());
BENCHMARK(BM_TraceLogger_Detailed)->Threads(1)->Threads(std::thread::hardware_concurrency());
BENCHMARK(BM_TraceLogger_Detailed_withArg)->Threads(1)->Threads(std::thread::hardware_concurrency());
} // namespace lms::core
BENCHMARK_MAIN();
} // namespace lms::core::benchs
+25 -1
View File
@@ -18,14 +18,38 @@
*/
#include "core/XxHash3.hpp"
#include "core/Exception.hpp"
#define XXH_INLINE_ALL
#include <xxhash.h>
namespace lms::core
{
std::uint64_t xxHash3_64(std::span<const std::byte> buf)
std::uint64_t XxHash3_64::hash(std::span<const std::byte> buf)
{
return XXH3_64bits(buf.data(), buf.size());
}
XxHash3_64::XxHash3_64()
: _state{ XXH3_createState() }
{
if (!_state)
throw LmsException{ "XXH3_createState failed: out of memory" };
XXH3_64bits_reset(static_cast<XXH3_state_t*>(_state));
}
XxHash3_64::~XxHash3_64()
{
XXH3_freeState(static_cast<XXH3_state_t*>(_state));
}
void XxHash3_64::update(std::span<const std::byte> buf)
{
XXH3_64bits_update(static_cast<XXH3_state_t*>(_state), buf.data(), buf.size());
}
std::uint64_t XxHash3_64::digest() const
{
return XXH3_64bits_digest(static_cast<const XXH3_state_t*>(_state));
}
} // namespace lms::core
@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cstddef>
#include <cstdlib>
#include "core/Exception.hpp"
namespace lms::core
{
template<typename T, std::size_t Alignment>
class AlignedHeapArray
{
public:
using iterator = T*;
using const_iterator = const T*;
explicit AlignedHeapArray(std::size_t count)
: _count{ count }
, _values{ static_cast<T*>(std::aligned_alloc(Alignment, getStorageSize(count))) }
{
if (!_values)
throw LmsException{ "Allocation failed" };
}
~AlignedHeapArray()
{
std::free(_values);
}
AlignedHeapArray(const AlignedHeapArray&) = delete;
AlignedHeapArray& operator=(const AlignedHeapArray&) = delete;
std::size_t size() const { return _count; }
T* data() const { return _values; }
T& operator[](std::size_t index) const { return _values[index]; }
iterator begin() const { return _values; }
iterator end() const { return _values + _count; }
const_iterator cbegin() const { return _values; }
const_iterator cend() const { return _values + _count; }
private:
static std::size_t getStorageSize(std::size_t count)
{
const std::size_t bytes{ count * sizeof(T) };
const std::size_t alignedBytes{ ((bytes + Alignment - 1) / Alignment) * Alignment };
return alignedBytes;
}
const std::size_t _count;
T* _values;
};
} // namespace lms::core
@@ -19,6 +19,8 @@
#pragma once
#include <cstdint>
#include <boost/crc.hpp> // for boost::crc_32_type
namespace lms::core
+25
View File
@@ -21,6 +21,7 @@
#include <algorithm>
#include <random>
#include <type_traits>
namespace lms::core::random
{
@@ -43,12 +44,36 @@ namespace lms::core::random
return dist(getRandGenerator());
}
template<typename RandomEngine, typename Container>
requires std::is_floating_point_v<typename Container::value_type>
void fillContainer(RandomEngine& randomEngine, Container& container, typename Container::value_type min, typename Container::value_type max)
{
std::uniform_real_distribution<typename Container::value_type> distrib{ min, max };
for (auto& v : container)
v = distrib(randomEngine);
}
template<typename RandomEngine, typename Container>
requires std::is_integral_v<typename Container::value_type>
void fillContainer(RandomEngine& randomEngine, Container& container, typename Container::value_type min, typename Container::value_type max)
{
std::uniform_int_distribution<typename Container::value_type> distrib{ min, max };
for (auto& v : container)
v = distrib(randomEngine);
}
template<typename Container>
void shuffleContainer(Container& container)
{
std::shuffle(std::begin(container), std::end(container), getRandGenerator());
}
template<typename RandomEngine, typename Container>
void shuffleContainer(RandomEngine& randomEngine, Container& container)
{
std::shuffle(std::begin(container), std::end(container), randomEngine);
}
template<typename Container>
typename Container::const_iterator pickRandom(const Container& container)
{
-10
View File
@@ -19,18 +19,8 @@
#pragma once
#include <algorithm>
#include <functional>
namespace lms::core::utils
{
template<typename Container, typename T>
void push_back_if_not_present(Container& container, const T& val)
{
if (std::find(std::cbegin(container), std::cend(container), val) == std::cend(container))
container.push_back(val);
}
template<class... Ts>
struct overloads : Ts...
{
+16 -1
View File
@@ -25,5 +25,20 @@
namespace lms::core
{
std::uint64_t xxHash3_64(std::span<const std::byte> buf);
class XxHash3_64
{
public:
static std::uint64_t hash(std::span<const std::byte> buf);
XxHash3_64();
~XxHash3_64();
XxHash3_64(const XxHash3_64&) = delete;
XxHash3_64& operator=(const XxHash3_64&) = delete;
void update(std::span<const std::byte> buf);
std::uint64_t digest() const;
private:
void* _state{};
};
} // namespace lms::core
+2 -1
View File
@@ -10,15 +10,16 @@ add_executable(test-core
Service.cpp
String.cpp
TraceLogger.cpp
Utils.cpp
UUID.cpp
XxHash3.cpp
)
target_link_libraries(test-core PRIVATE
lmscore
lmsmath
Threads::Threads
GTest::GTest
GTest::gtest_main
)
if (NOT CMAKE_CROSSCOMPILING)
+21 -1
View File
@@ -31,7 +31,27 @@ namespace lms::core
for (std::size_t i{}; i < buffer.size(); ++i)
buffer[i] = static_cast<std::byte>(i);
const std::uint64_t hash{ xxHash3_64(buffer) };
const std::uint64_t hash{ XxHash3_64::hash(buffer) };
EXPECT_EQ(hash, 12137474952470826274ULL);
}
TEST(Xxhash3_64, streamingMatchesOneShot)
{
std::vector<std::byte> buffer;
buffer.resize(1024);
for (std::size_t i{}; i < buffer.size(); ++i)
buffer[i] = static_cast<std::byte>(i);
const std::uint64_t expected{ XxHash3_64::hash(buffer) };
// Feed the same data in three unequal chunks to exercise the streaming path
constexpr std::size_t chunk1{ 100 };
constexpr std::size_t chunk2{ 400 };
XxHash3_64 hasher;
hasher.update(std::span{ buffer }.subspan(0, chunk1));
hasher.update(std::span{ buffer }.subspan(chunk1, chunk2));
hasher.update(std::span{ buffer }.subspan(chunk1 + chunk2));
EXPECT_EQ(hasher.digest(), expected);
}
} // namespace lms::core
+3 -2
View File
@@ -28,7 +28,7 @@ add_library(lmsdatabase STATIC
impl/objects/TrackBookmark.cpp
impl/objects/TrackEmbeddedImage.cpp
impl/objects/TrackEmbeddedImageLink.cpp
impl/objects/TrackFeatures.cpp
impl/objects/TrackMusicNNEmbeddings.cpp
impl/objects/TrackList.cpp
impl/objects/TrackLyrics.cpp
impl/objects/Types.cpp
@@ -38,7 +38,7 @@ add_library(lmsdatabase STATIC
impl/IdType.cpp
impl/Migration.cpp
impl/Object.cpp
impl/QueryPlanRecorder.cpp
impl/profiling/QueryProfiler.cpp
impl/Session.cpp
impl/SqlQuery.cpp
impl/Transaction.cpp
@@ -59,6 +59,7 @@ target_include_directories(lmsdatabase PRIVATE
)
target_link_libraries(lmsdatabase PRIVATE
lmsmath
Wt::DboSqlite3
)
+19 -1
View File
@@ -35,7 +35,7 @@ namespace lms::db
{
namespace
{
static constexpr Version LMS_DATABASE_VERSION{ 103 };
static constexpr Version LMS_DATABASE_VERSION{ 104 };
}
VersionInfo::VersionInfo()
@@ -1706,6 +1706,23 @@ FROM track)");
utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1");
}
void migrateFromV103(Session& session)
{
// Drop previous track_audio_features with a brand new table dedicated to embeddings
utils::executeCommand(*session.getDboSession(), R"(DROP TABLE track_features)");
utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "track_musicnn_embeddings" (
"id" integer primary key autoincrement,
"version" integer not null,
"data" blob not null,
"track_id" bigint,
constraint "fk_track_musicnn_embeddings_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred
))");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings RENAME COLUMN similarity_engine_type TO recommendation_engine_type");
utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN musicnn_model_identifier TEXT NOT NULL DEFAULT ''");
}
bool doDbMigration(Session& session)
{
constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" };
@@ -1785,6 +1802,7 @@ FROM track)");
{ 100, migrateFromV100 },
{ 101, migrateFromV101 },
{ 102, migrateFromV102 },
{ 103, migrateFromV103 },
};
bool migrationPerformed{};
+3 -3
View File
@@ -52,9 +52,9 @@
#include "database/objects/TrackBookmark.hpp"
#include "database/objects/TrackEmbeddedImage.hpp"
#include "database/objects/TrackEmbeddedImageLink.hpp"
#include "database/objects/TrackFeatures.hpp"
#include "database/objects/TrackList.hpp"
#include "database/objects/TrackLyrics.hpp"
#include "database/objects/TrackMusicNNEmbeddings.hpp"
#include "database/objects/UIState.hpp"
#include "database/objects/User.hpp"
@@ -106,7 +106,7 @@ namespace lms::db
_session.mapClass<TrackArtistLink>("track_artist_link");
_session.mapClass<TrackEmbeddedImage>("track_embedded_image");
_session.mapClass<TrackEmbeddedImageLink>("track_embedded_image_link");
_session.mapClass<TrackFeatures>("track_features");
_session.mapClass<TrackMusicNNEmbeddings>("track_musicnn_embeddings");
_session.mapClass<TrackList>("tracklist");
_session.mapClass<TrackListEntry>("tracklist_entry");
_session.mapClass<TrackLyrics>("track_lyrics");
@@ -302,7 +302,7 @@ namespace lms::db
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_type_idx ON track_artist_link(track_id, type)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_features_track_idx ON track_features(track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_musicnn_embeddings_track_idx ON track_musicnn_embeddings(track_id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_id_idx ON track_lyrics(id)");
utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_absolute_file_path_idx ON track_lyrics(absolute_file_path)");
+13 -21
View File
@@ -29,10 +29,9 @@
#include <Wt/WDateTime.h>
#include "core/ITraceLogger.hpp"
#include "core/Service.hpp"
#include "database/Types.hpp"
#include "QueryPlanRecorder.hpp"
#include "profiling/ScopedQueryProfiler.hpp"
namespace lms::db::utils
{
@@ -42,16 +41,6 @@ namespace lms::db::utils
Wt::WDateTime normalizeDateTime(const Wt::WDateTime& dateTime);
namespace detail
{
template<typename Query>
void recordQueryPlanIfNeeded(const Query& query)
{
if (IQueryPlanRecorder * recorder{ core::Service<IQueryPlanRecorder>::get() })
static_cast<QueryPlanRecorder*>(recorder)->recordQueryPlanIfNeeded(query.session(), query.asString());
}
} // namespace detail
template<typename Query>
void applyRange(Query& query, std::optional<Range> range)
{
@@ -100,20 +89,22 @@ namespace lms::db::utils
template<typename Query, typename UnaryFunc>
void forEachQueryResult(const Query& query, UnaryFunc&& func)
{
detail::recordQueryPlanIfNeeded(query);
LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "ForEachQueryResult", "Query", query.asString());
forEachResult(query.resultList(), std::forward<UnaryFunc>(func));
ScopedQueryProfiler queryProfiler{ query };
forEachResult(query.resultList(), [&](const auto& result) {
queryProfiler.suspend();
func(result);
queryProfiler.resume();
});
}
template<typename T, typename Query>
std::vector<T> fetchQueryResults(const Query& query)
{
detail::recordQueryPlanIfNeeded(query);
LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQueryResults", "Query", query.asString());
ScopedQueryProfiler queryProfiler{ query };
auto collection{ query.resultList() };
return std::vector<T>(collection.begin(), collection.end());
}
@@ -121,10 +112,9 @@ namespace lms::db::utils
template<typename Query>
std::vector<typename QueryResultType<Query>::type> fetchQueryResults(const Query& query)
{
detail::recordQueryPlanIfNeeded(query);
LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQueryResults", "Query", query.asString());
ScopedQueryProfiler queryProfiler{ query };
auto collection{ query.resultList() };
return std::vector<typename QueryResultType<Query>::type>(collection.begin(), collection.end());
}
@@ -132,9 +122,8 @@ namespace lms::db::utils
template<typename Query>
auto fetchQuerySingleResult(const Query& query)
{
detail::recordQueryPlanIfNeeded(query);
LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQuerySingleResult", "Query", query.asString());
ScopedQueryProfiler queryProfiler{ query };
return query.resultValue();
}
@@ -184,6 +173,7 @@ namespace lms::db::utils
moreResults = false;
std::size_t count{};
ScopedQueryProfiler queryProfiler{ query };
const auto collection{ query.resultList() };
auto it{ fetchFirstResult(collection) };
while (it != collection.end())
@@ -194,7 +184,9 @@ namespace lms::db::utils
break;
}
queryProfiler.suspend();
func(*it);
queryProfiler.resume();
fetchNextResult<ResultType>(it);
}
}
-42
View File
@@ -403,48 +403,6 @@ AND NOT EXISTS (
return _preferredArtwork.id();
}
RangeResults<ArtistId> Artist::findSimilarArtistIds(core::EnumSet<TrackArtistLinkType> artistLinkTypes, std::optional<Range> range) const
{
assert(session());
std::ostringstream oss;
oss << "SELECT a.id FROM artist a"
" INNER JOIN track_artist_link t_a_l ON t_a_l.artist_id = a.id"
" INNER JOIN track t ON t.id = t_a_l.track_id"
" INNER JOIN track_cluster t_c ON t_c.track_id = t.id"
" WHERE "
" t_c.cluster_id IN (SELECT DISTINCT c.id from cluster c"
" INNER JOIN track t ON c.id = t_c.cluster_id"
" INNER JOIN track_cluster t_c ON t_c.track_id = t.id"
" INNER JOIN artist a ON a.id = t_a_l.artist_id"
" INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id"
" WHERE a.id = ?)"
" AND a.id <> ?";
if (!artistLinkTypes.empty())
{
oss << " AND t_a_l.type IN (";
bool first{ true };
for (TrackArtistLinkType type : artistLinkTypes)
{
(void)type;
if (!first)
oss << ", ";
oss << "?";
first = false;
}
oss << ")";
}
auto query{ session()->query<ArtistId>(oss.str()).bind(getId()).bind(getId()).groupBy("a.id").orderBy("COUNT(*) DESC, RANDOM()") };
for (const TrackArtistLinkType type : artistLinkTypes)
query.bind(type);
return utils::execRangeQuery<ArtistId>(query, range);
}
std::vector<std::vector<Cluster::pointer>> Artist::getClusterGroups(std::span<const ClusterTypeId> clusterTypeIds, std::size_t size) const
{
assert(session());
@@ -749,33 +749,6 @@ namespace lms::db
return utils::fetchQueryResults(query);
}
std::vector<Release::pointer> Release::getSimilarReleases(std::optional<std::size_t> offset, std::optional<std::size_t> count) const
{
assert(session());
// Select the similar releases using the 5 most used clusters of the release
auto query{ session()->query<Wt::Dbo::ptr<Release>>(
"SELECT r FROM release r"
" INNER JOIN track t ON t.release_id = r.id"
" INNER JOIN track_cluster t_c ON t_c.track_id = t.id"
" WHERE "
" t_c.cluster_id IN "
"(SELECT DISTINCT c.id FROM cluster c"
" INNER JOIN track t ON c.id = t_c.cluster_id"
" INNER JOIN track_cluster t_c ON t_c.track_id = t.id"
" INNER JOIN release r ON r.id = t.release_id"
" WHERE r.id = ?)"
" AND r.id <> ?")
.bind(getId())
.bind(getId())
.groupBy("r.id")
.orderBy("COUNT(*) DESC, RANDOM()")
.limit(count ? static_cast<int>(*count) : -1)
.offset(offset ? static_cast<int>(*offset) : -1) };
return utils::fetchQueryResults<Release::pointer>(query);
}
ObjectPtr<Artwork> Release::getPreferredArtwork() const
{
return ObjectPtr<Artwork>{ _preferredArtwork };
+30 -38
View File
@@ -36,7 +36,6 @@
#include "database/objects/TrackArtistLink.hpp"
#include "database/objects/TrackEmbeddedImage.hpp"
#include "database/objects/TrackEmbeddedImageLink.hpp"
#include "database/objects/TrackFeatures.hpp"
#include "database/objects/TrackLyrics.hpp"
#include "database/objects/User.hpp"
@@ -191,6 +190,20 @@ namespace lms::db
if (params.fileSize.has_value())
query.where("t.file_size = ?").bind(static_cast<long long>(params.fileSize.value()));
if (params.hasMusicNNEmbeddings.has_value())
{
if (*params.hasMusicNNEmbeddings)
query.where("EXISTS (SELECT t_m_e.track_id FROM track_musicnn_embeddings t_m_e WHERE t_m_e.track_id = t.id)");
else
query.where("NOT EXISTS (SELECT t_m_e.track_id FROM track_musicnn_embeddings t_m_e WHERE t_m_e.track_id = t.id)");
}
if (params.lastTrackId.isValid())
{
assert(params.sortMethod == TrackSortMethod::Id);
query.where("t.id > ?").bind(params.lastTrackId);
}
if (params.embeddedImageId.isValid())
{
query.join("track_embedded_image_link t_e_i_l ON t_e_i_l.track_id = t.id");
@@ -322,7 +335,7 @@ namespace lms::db
});
}
void Track::findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const std::function<void(TrackId trackId, const std::filesystem::path& absoluteFilePath)>& func)
void Track::findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const TrackLocationVisitor& func)
{
session.checkReadTransaction();
@@ -334,6 +347,19 @@ namespace lms::db
});
}
void Track::findAbsoluteFilePath(Session& session, const FindParameters& params, const TrackLocationVisitor& func)
{
session.checkReadTransaction();
std::string_view itemToSelect{ "t.id, t.absolute_file_path" };
auto query{ createQuery<std::tuple<TrackId, std::filesystem::path>>(session, itemToSelect, params) };
utils::forEachQueryRangeResult(query, params.range, [&](const auto& res) {
func(std::get<0>(res), std::get<1>(res));
});
}
void Track::find(Session& session, const IdRange<TrackId>& idRange, const std::function<void(const Track::pointer&)>& func)
{
assert(idRange.isValid());
@@ -385,15 +411,6 @@ namespace lms::db
return utils::execRangeQuery<TrackId>(query, range);
}
RangeResults<TrackId> Track::findIdsWithRecordingMBIDAndMissingFeatures(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<TrackId>("SELECT t.id FROM track t").where("LENGTH(t.recording_mbid) > 0").where("NOT EXISTS (SELECT * FROM track_features t_f WHERE t_f.track_id = t.id)") };
return utils::execRangeQuery<TrackId>(query, range);
}
void Track::updatePreferredArtwork(Session& session, TrackId trackId, ArtworkId artworkId)
{
session.checkWriteTransaction();
@@ -490,36 +507,11 @@ namespace lms::db
utils::forEachQueryRangeResult(query, params.range, moreResults, func);
}
RangeResults<TrackId> Track::findSimilarTrackIds(Session& session, const std::vector<TrackId>& tracks, std::optional<Range> range)
std::size_t Track::getCount(Session& session, const FindParameters& params)
{
assert(!tracks.empty());
session.checkReadTransaction();
std::ostringstream oss;
for (std::size_t i{}; i < tracks.size(); ++i)
{
if (!oss.str().empty())
oss << ", ";
oss << "?";
}
auto query{ session.getDboSession()->query<TrackId>(
"SELECT t.id FROM track t"
" INNER JOIN track_cluster t_c ON t_c.track_id = t.id"
" AND t_c.cluster_id IN (SELECT DISTINCT c.id FROM cluster c INNER JOIN track_cluster t_c ON t_c.cluster_id = c.id WHERE t_c.track_id IN ("
+ oss.str() + "))"
" AND t.id NOT IN ("
+ oss.str() + ")")
.groupBy("t.id")
.orderBy("COUNT(*) DESC, RANDOM()") };
for (TrackId trackId : tracks)
query.bind(trackId);
for (TrackId trackId : tracks)
query.bind(trackId);
return utils::execRangeQuery<TrackId>(query, range);
return utils::fetchQuerySingleResult(createQuery<int>(session, "COUNT(*)", params));
}
void Track::setAbsoluteFilePath(const std::filesystem::path& filePath)
@@ -1,123 +0,0 @@
/*
* Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
*/
#include "database/objects/TrackFeatures.hpp"
#include <Wt/Dbo/Impl.h>
#include <boost/property_tree/json_parser.hpp>
#include <boost/property_tree/ptree.hpp>
#include "core/ILogger.hpp"
#include "database/Session.hpp"
#include "database/objects/Directory.hpp"
#include "database/objects/Track.hpp"
#include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
DBO_INSTANTIATE_TEMPLATES(lms::db::TrackFeatures)
namespace lms::db
{
TrackFeatures::TrackFeatures(ObjectPtr<Track> track, const std::string& jsonEncodedFeatures)
: _data{ jsonEncodedFeatures }
, _track{ getDboPtr(track) }
{
}
TrackFeatures::pointer TrackFeatures::create(Session& session, ObjectPtr<Track> track, const std::string& jsonEncodedFeatures)
{
return session.getDboSession()->add(std::unique_ptr<TrackFeatures>{ new TrackFeatures{ track, jsonEncodedFeatures } });
}
std::size_t TrackFeatures::getCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM track_features"));
}
TrackFeatures::pointer TrackFeatures::find(Session& session, TrackFeaturesId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<TrackFeatures>().where("id = ?").bind(id));
}
TrackFeatures::pointer TrackFeatures::find(Session& session, TrackId trackId)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<TrackFeatures>().where("track_id = ?").bind(trackId));
}
RangeResults<TrackFeaturesId> TrackFeatures::find(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<TrackFeaturesId>("SELECT id from track_features") };
return utils::execRangeQuery<TrackFeaturesId>(query, range);
}
FeatureValues TrackFeatures::getFeatureValues(const FeatureName& featureNode) const
{
FeatureValuesMap featuresValuesMap{ getFeatureValuesMap({ featureNode }) };
return std::move(featuresValuesMap[featureNode]);
}
FeatureValuesMap TrackFeatures::getFeatureValuesMap(const std::unordered_set<FeatureName>& featureNames) const
{
FeatureValuesMap res;
try
{
std::istringstream iss{ _data };
boost::property_tree::ptree root;
boost::property_tree::read_json(iss, root);
for (const FeatureName& featureName : featureNames)
{
FeatureValues& featureValues{ res[featureName] };
auto node{ root.get_child(featureName) };
bool hasChildren = false;
for (const auto& child : node.get_child(""))
{
hasChildren = true;
featureValues.push_back(child.second.get_value<double>());
}
if (!hasChildren)
featureValues.push_back(node.get_value<double>());
}
}
catch (boost::property_tree::ptree_error& error)
{
LMS_LOG(DB, ERROR, "Track " << _track.id() << ": ptree exception: " << error.what());
res.clear();
}
return res;
}
} // namespace lms::db
@@ -0,0 +1,99 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include "database/objects/TrackMusicNNEmbeddings.hpp"
#include <Wt/Dbo/Impl.h>
#include "database/Session.hpp"
#include "database/objects/Track.hpp"
#include "Utils.hpp"
#include "traits/IdTypeTraits.hpp"
DBO_INSTANTIATE_TEMPLATES(lms::db::TrackMusicNNEmbeddings)
namespace lms::db
{
TrackMusicNNEmbeddings::TrackMusicNNEmbeddings(ObjectPtr<Track> track)
: _track{ getDboPtr(track) }
{
}
TrackMusicNNEmbeddings::pointer TrackMusicNNEmbeddings::create(Session& session, ObjectPtr<Track> track)
{
return session.getDboSession()->add(std::unique_ptr<TrackMusicNNEmbeddings>{ new TrackMusicNNEmbeddings{ track } });
}
std::size_t TrackMusicNNEmbeddings::getCount(Session& session)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM track_musicnn_embeddings"));
}
TrackMusicNNEmbeddings::pointer TrackMusicNNEmbeddings::find(Session& session, TrackMusicNNEmbeddingsId id)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<TrackMusicNNEmbeddings>().where("id = ?").bind(id));
}
TrackMusicNNEmbeddings::pointer TrackMusicNNEmbeddings::find(Session& session, TrackId trackId)
{
session.checkReadTransaction();
return utils::fetchQuerySingleResult(session.getDboSession()->find<TrackMusicNNEmbeddings>().where("track_id = ?").bind(trackId));
}
RangeResults<TrackMusicNNEmbeddingsId> TrackMusicNNEmbeddings::find(Session& session, std::optional<Range> range)
{
session.checkReadTransaction();
auto query{ session.getDboSession()->query<TrackMusicNNEmbeddingsId>("SELECT id from track_musicnn_embeddings") };
return utils::execRangeQuery<TrackMusicNNEmbeddingsId>(query, range);
}
void TrackMusicNNEmbeddings::find(Session& session, std::function<void(const pointer&)> func)
{
auto query{ session.getDboSession()->find<TrackMusicNNEmbeddings>() };
utils::forEachQueryResult(query, [&](const TrackMusicNNEmbeddings::pointer& embeddings) {
func(embeddings);
});
}
void TrackMusicNNEmbeddings::removeAll(Session& session)
{
session.checkWriteTransaction();
utils::executeCommand(*session.getDboSession(), "DELETE FROM track_musicnn_embeddings");
}
std::span<const std::byte> TrackMusicNNEmbeddings::getData() const
{
return std::span<const std::byte>{ reinterpret_cast<const std::byte*>(_data.data()), _data.size() };
}
void TrackMusicNNEmbeddings::setData(std::span<const std::byte> data)
{
const auto* start{ reinterpret_cast<const unsigned char*>(data.data()) };
_data.assign(start, start + data.size());
}
} // namespace lms::db
@@ -17,7 +17,7 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "QueryPlanRecorder.hpp"
#include "profiling/QueryProfiler.hpp"
#include <memory>
#include <mutex>
@@ -29,35 +29,38 @@
namespace lms::db
{
std::unique_ptr<IQueryPlanRecorder> createQueryPlanRecorder()
std::unique_ptr<IQueryProfiler> createQueryProfiler()
{
return std::make_unique<QueryPlanRecorder>();
return std::make_unique<QueryProfiler>();
}
QueryPlanRecorder::QueryPlanRecorder()
QueryProfiler::QueryProfiler()
{
LMS_LOG(DB, INFO, "Recording database query plans");
LMS_LOG(DB, INFO, "Recording database queries");
}
QueryPlanRecorder::~QueryPlanRecorder() = default;
QueryProfiler::~QueryProfiler() = default;
void QueryPlanRecorder::visitQueryPlans(const QueryPlanVisitor& visitor) const
void QueryProfiler::visitQueries(const QueryVisitor& visitor) const
{
const std::shared_lock lock{ _mutex };
for (const auto& [query, plan] : _queryPlans)
visitor(query, plan);
for (const auto& [query, data] : _queries)
{
const QueryStats stats{
.query = query,
.plan = data.plan,
.callCount = data.timeStats.getCount(),
.totalTime = std::chrono::microseconds{ static_cast<long long>(data.timeStats.getMean() * static_cast<double>(data.timeStats.getCount())) },
.meanTime = std::chrono::microseconds{ static_cast<long long>(data.timeStats.getMean()) },
.stdDevTime = std::chrono::microseconds{ static_cast<long long>(data.timeStats.getSampleStdDev()) },
};
visitor(stats);
}
}
void QueryPlanRecorder::recordQueryPlanIfNeeded(Wt::Dbo::Session& session, const std::string& query)
void QueryProfiler::recordQueryPlan(Wt::Dbo::Session& session, const std::string& query)
{
{
const std::shared_lock lock{ _mutex };
if (_queryPlans.contains(query))
return;
}
Wt::Dbo::Transaction transaction{ session };
Wt::Dbo::SqlConnection* connection{ transaction.connection() };
@@ -106,7 +109,23 @@ namespace lms::db
{
const std::unique_lock lock{ _mutex };
_queryPlans.try_emplace(query, std::move(result));
_queries[query].plan = std::move(result);
}
}
void QueryProfiler::recordQueryExecution(Wt::Dbo::Session& session, const std::string& query, Clock::duration elapsed)
{
bool needQueryPlan{};
const double elapsedUs{ std::chrono::duration_cast<std::chrono::duration<double, std::micro>>(elapsed).count() };
{
std::unique_lock lock{ _mutex };
auto& queryStats{ _queries[query] };
queryStats.timeStats.add(elapsedUs);
needQueryPlan = queryStats.plan.empty();
}
if (needQueryPlan)
recordQueryPlan(session, query);
}
} // namespace lms::db
@@ -25,24 +25,33 @@
#include <Wt/Dbo/Session.h>
#include "database/IQueryPlanRecorder.hpp"
#include "database/profiling/IQueryProfiler.hpp"
#include "math/StatsAccumulator.hpp"
namespace lms::db
{
class QueryPlanRecorder : public IQueryPlanRecorder
class QueryProfiler : public IQueryProfiler
{
public:
QueryPlanRecorder();
~QueryPlanRecorder() override;
QueryPlanRecorder(const QueryPlanRecorder&) = delete;
QueryPlanRecorder& operator=(const QueryPlanRecorder&) = delete;
QueryProfiler();
~QueryProfiler() override;
QueryProfiler(const QueryProfiler&) = delete;
QueryProfiler& operator=(const QueryProfiler&) = delete;
void visitQueryPlans(const QueryPlanVisitor& visitor) const override;
void visitQueries(const QueryVisitor& visitor) const override;
void recordQueryPlanIfNeeded(Wt::Dbo::Session& session, const std::string& query);
void recordQueryExecution(Wt::Dbo::Session& session, const std::string& query, Clock::duration elapsed);
private:
void recordQueryPlan(Wt::Dbo::Session& session, const std::string& query);
struct QueryData
{
std::string plan;
math::StatsAccumulator<double> timeStats; // in Us
};
mutable std::shared_mutex _mutex;
std::map<std::string, std::string> _queryPlans;
std::map<std::string, QueryData> _queries;
};
} // namespace lms::db
@@ -0,0 +1,86 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cassert>
#include <string>
#include "core/Service.hpp"
#include "database/profiling/IQueryProfiler.hpp"
#include "profiling/QueryProfiler.hpp"
namespace lms::db::utils
{
template<typename Query>
class ScopedQueryProfiler
{
public:
explicit ScopedQueryProfiler(const Query& query)
: _recorder{ static_cast<QueryProfiler*>(core::Service<IQueryProfiler>::get()) }
{
if (_recorder)
{
_query = &query;
_start = IQueryProfiler::Clock::now();
}
}
~ScopedQueryProfiler()
{
if (_recorder)
{
if (_active)
_elapsed += IQueryProfiler::Clock::now() - _start;
_recorder->recordQueryExecution(_query->session(), _query->asString(), _elapsed);
}
}
ScopedQueryProfiler(const ScopedQueryProfiler&) = delete;
ScopedQueryProfiler& operator=(const ScopedQueryProfiler&) = delete;
void suspend()
{
if (_recorder)
{
assert(_active);
_elapsed += IQueryProfiler::Clock::now() - _start;
_active = false;
}
}
void resume()
{
if (_recorder)
{
assert(!_active);
_start = IQueryProfiler::Clock::now();
_active = true;
}
}
private:
QueryProfiler* _recorder{};
const Query* _query{};
IQueryProfiler::Clock::time_point _start;
IQueryProfiler::Clock::duration _elapsed{};
bool _active{ true };
};
} // namespace lms::db::utils
@@ -29,7 +29,6 @@
#include <Wt/Dbo/collection.h>
#include <Wt/WDateTime.h>
#include "core/EnumSet.hpp"
#include "core/UUID.hpp"
#include "database/IdRange.hpp"
@@ -39,7 +38,6 @@
#include "database/objects/ArtworkId.hpp"
#include "database/objects/Filters.hpp"
#include "database/objects/MediaLibraryId.hpp"
#include "database/objects/ReleaseId.hpp"
#include "database/objects/TrackId.hpp"
#include "database/objects/Types.hpp"
#include "database/objects/UserId.hpp"
@@ -148,9 +146,6 @@ namespace lms::db
ObjectPtr<Artwork> getPreferredArtwork() const;
ArtworkId getPreferredArtworkId() const;
// No artistLinkTypes means get them all
RangeResults<ArtistId> findSimilarArtistIds(core::EnumSet<TrackArtistLinkType> artistLinkTypes = {}, std::optional<Range> range = std::nullopt) const;
// Get the cluster of the tracks made by this artist
// Each clusters are grouped by cluster type, sorted by the number of occurence
// size is the max number of cluster per cluster type
@@ -349,8 +349,6 @@ namespace lms::db
void visitTrackArtists(TrackArtistLinkType type, std::function<void(const ObjectPtr<Artist>&)> visitor) const;
std::vector<ArtistId> getTrackArtistIds(TrackArtistLinkType type = TrackArtistLinkType::Artist) const;
bool hasVariousArtists() const;
std::vector<pointer> getSimilarReleases(std::optional<std::size_t> offset = {}, std::optional<std::size_t> count = {}) const;
template<class Action>
void persist(Action& a)
{
@@ -50,11 +50,11 @@ namespace lms::db
};
// Do not modify values (just add)
enum class SimilarityEngineType
enum class RecommendationEngineType
{
Clusters = 0,
Features,
None,
None = 2,
AudioSimilarity = 3,
};
ScanSettings() = default;
@@ -65,10 +65,11 @@ namespace lms::db
// Getters
std::size_t getAudioScanVersion() const { return _audioScanVersion; }
std::size_t getArtistInfoScanVersion() const { return _artistInfoScanVersion; }
std::string_view getMusicNNModelIdentifier() const { return _musicnnModelIdentifier; }
Wt::WTime getUpdateStartTime() const { return _startTime; }
UpdatePeriod getUpdatePeriod() const { return _updatePeriod; }
std::vector<std::string_view> getExtraTagsToScan() const;
SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; }
RecommendationEngineType getRecommendationEngineType() const { return _recommendationEngineType; }
std::vector<std::string> getArtistTagDelimiters() const;
std::vector<std::string> getDefaultTagDelimiters() const;
std::vector<std::string> getArtistsToNotSplit() const;
@@ -80,14 +81,14 @@ namespace lms::db
void setUpdateStartTime(Wt::WTime t) { _startTime = t; }
void setUpdatePeriod(UpdatePeriod p) { _updatePeriod = p; }
void setExtraTagsToScan(std::span<const std::string_view> extraTags);
void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; }
void setRecommendationEngineType(RecommendationEngineType type) { _recommendationEngineType = type; }
void setArtistTagDelimiters(std::span<const std::string_view> delimiters);
void setArtistsToNotSplit(std::span<const std::string_view> artists);
void setDefaultTagDelimiters(std::span<const std::string_view> delimiters);
void setSkipSingleReleasePlayLists(bool value);
void setAllowMBIDArtistMerge(bool value);
void setArtistImageFallbackToReleaseField(bool value);
void setMusicNNModelIdentifier(std::string_view identifier) { _musicnnModelIdentifier = identifier; }
template<class Action>
void persist(Action& a)
{
@@ -96,7 +97,7 @@ namespace lms::db
Wt::Dbo::field(a, _artistInfoScanVersion, "artist_info_scan_version");
Wt::Dbo::field(a, _startTime, "start_time");
Wt::Dbo::field(a, _updatePeriod, "update_period");
Wt::Dbo::field(a, _similarityEngineType, "similarity_engine_type");
Wt::Dbo::field(a, _recommendationEngineType, "recommendation_engine_type");
Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan");
Wt::Dbo::field(a, _artistTagDelimiters, "artist_tag_delimiters");
Wt::Dbo::field(a, _artistsToNotSplit, "artists_to_not_split");
@@ -104,6 +105,7 @@ namespace lms::db
Wt::Dbo::field(a, _skipSingleReleasePlayLists, "skip_single_release_playlists");
Wt::Dbo::field(a, _allowMBIDArtistMerge, "allow_mbid_artist_merge");
Wt::Dbo::field(a, _artistImageFallbackToReleaseField, "artist_image_fallback_to_release");
Wt::Dbo::field(a, _musicnnModelIdentifier, "musicnn_model_identifier");
}
private:
@@ -119,7 +121,7 @@ namespace lms::db
int _artistInfoScanVersion{};
Wt::WTime _startTime = Wt::WTime{ 0, 0, 0 };
UpdatePeriod _updatePeriod{ UpdatePeriod::Never };
SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters };
RecommendationEngineType _recommendationEngineType{ RecommendationEngineType::Clusters };
std::string _extraTagsToScan;
std::string _artistTagDelimiters;
std::string _artistsToNotSplit;
@@ -127,5 +129,6 @@ namespace lms::db
bool _skipSingleReleasePlayLists{};
bool _allowMBIDArtistMerge{};
bool _artistImageFallbackToReleaseField{};
std::string _musicnnModelIdentifier;
};
} // namespace lms::db
@@ -97,6 +97,8 @@ namespace lms::db
DirectoryId directory; // if set, tracks in this directory
std::optional<std::size_t> fileSize; // if set, tracks that match this file size
TrackEmbeddedImageId embeddedImageId; // if set, tracks that have this embedded image
std::optional<bool> hasMusicNNEmbeddings; // If set, tracks that have (or not) MusicNN embeddings
TrackId lastTrackId; // If set, tracks that are after this one, must be used with sort by id
FindParameters& setFilters(const Filters& _filters)
{
@@ -191,6 +193,16 @@ namespace lms::db
embeddedImageId = _embeddedImageId;
return *this;
}
FindParameters& setHasMusicNNEmbeddings(std::optional<bool> _hasMusicNNEmbeddings)
{
hasMusicNNEmbeddings = _hasMusicNNEmbeddings;
return *this;
}
FindParameters& setLastTrackId(TrackId _lastTrackId)
{
lastTrackId = _lastTrackId;
return *this;
}
};
Track() = default;
@@ -203,19 +215,20 @@ namespace lms::db
static void find(Session& session, TrackId& lastRetrievedId, std::size_t count, const std::function<void(const Track::pointer&)>& func, MediaLibraryId library = {});
static void find(Session& session, const IdRange<TrackId>& idRange, const std::function<void(const Track::pointer&)>& func);
static IdRange<TrackId> findNextIdRange(Session& session, TrackId lastRetrievedId, std::size_t count);
static void findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const std::function<void(TrackId trackId, const std::filesystem::path& absoluteFilePath)>& func);
using TrackLocationVisitor = std::function<void(TrackId trackId, const std::filesystem::path& absoluteFilePath)>;
static void findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const TrackLocationVisitor& func);
static void findAbsoluteFilePath(Session& session, const FindParameters& params, const TrackLocationVisitor& func);
static bool exists(Session& session, TrackId id);
static std::vector<pointer> findByRecordingMBID(Session& session, const core::UUID& MBID);
static std::vector<pointer> findByMBID(Session& session, const core::UUID& MBID);
static RangeResults<TrackId> findSimilarTrackIds(Session& session, const std::vector<TrackId>& trackIds, std::optional<Range> range = std::nullopt);
static RangeResults<TrackId> findIds(Session& session, const FindParameters& parameters);
static RangeResults<pointer> find(Session& session, const FindParameters& parameters);
static void find(Session& session, const FindParameters& parameters, const std::function<void(const Track::pointer&)>& func);
static void find(Session& session, const FindParameters& parameters, bool& moreResults, const std::function<void(const Track::pointer&)>& func);
static RangeResults<TrackId> findIds(Session& session, const FindParameters& params);
static RangeResults<pointer> find(Session& session, const FindParameters& params);
static void find(Session& session, const FindParameters& params, const std::function<void(const Track::pointer&)>& func);
static void find(Session& session, const FindParameters& params, bool& moreResults, const std::function<void(const Track::pointer&)>& func);
static std::size_t getCount(Session& session, const FindParameters& params);
static RangeResults<TrackId> findIdsTrackMBIDDuplicates(Session& session, std::optional<Range> range = std::nullopt);
static RangeResults<TrackId> findIdsWithRecordingMBIDAndMissingFeatures(Session& session, std::optional<Range> range = std::nullopt);
// Update utility functions
static void updatePreferredArtwork(Session& session, TrackId trackId, ArtworkId artworkId);
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2018 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -20,10 +20,7 @@
#pragma once
#include <optional>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <span>
#include <Wt/Dbo/Field.h>
@@ -32,34 +29,33 @@
#include "database/Types.hpp"
#include "database/objects/TrackId.hpp"
LMS_DECLARE_IDTYPE(TrackFeaturesId)
LMS_DECLARE_IDTYPE(TrackMusicNNEmbeddingsId)
namespace lms::db
{
class Session;
class Track;
using FeatureName = std::string;
using FeatureValues = std::vector<double>;
using FeatureValuesMap = std::unordered_map<FeatureName, FeatureValues>;
class TrackFeatures final : public Object<TrackFeatures, TrackFeaturesId>
class TrackMusicNNEmbeddings final : public Object<TrackMusicNNEmbeddings, TrackMusicNNEmbeddingsId>
{
public:
TrackFeatures() = default;
TrackMusicNNEmbeddings() = default;
// Find utilities
static std::size_t getCount(Session& session);
static pointer find(Session& session, TrackFeaturesId id);
static pointer find(Session& session, TrackMusicNNEmbeddingsId id);
static pointer find(Session& session, TrackId trackId);
static RangeResults<TrackFeaturesId> find(Session& session, std::optional<Range> range = std::nullopt);
FeatureValues getFeatureValues(const FeatureName& feature) const;
FeatureValuesMap getFeatureValuesMap(const std::unordered_set<FeatureName>& featureNames) const;
static RangeResults<TrackMusicNNEmbeddingsId> find(Session& session, std::optional<Range> range = std::nullopt);
static void find(Session& session, std::function<void(const pointer&)> func);
static void removeAll(Session& session);
// Accessors
std::span<const std::byte> getData() const;
TrackId getTrackId() const { return _track.id(); }
Wt::Dbo::ptr<Track> getTrack() const { return _track; }
void setData(std::span<const std::byte> data);
template<class Action>
void persist(Action& a)
{
@@ -69,11 +65,10 @@ namespace lms::db
private:
friend class Session;
TrackFeatures(ObjectPtr<Track> track, const std::string& jsonEncodedFeatures);
static pointer create(Session& session, ObjectPtr<Track> track, const std::string& jsonEncodedFeatures);
TrackMusicNNEmbeddings(ObjectPtr<Track> track);
static pointer create(Session& session, ObjectPtr<Track> track);
std::string _data;
std::vector<unsigned char> _data;
Wt::Dbo::ptr<Track> _track;
};
} // namespace lms::db
@@ -19,21 +19,35 @@
#pragma once
#include <chrono>
#include <functional>
#include <memory>
#include <string_view>
namespace lms::db
{
// Due to technical limitations, query plans are recorded globally across all databases.
// As a result, this class is implemented as a singleton rather than being owned per DB instance.
class IQueryPlanRecorder
class IQueryProfiler
{
public:
virtual ~IQueryPlanRecorder() = default;
virtual ~IQueryProfiler() = default;
using QueryPlanVisitor = std::function<void(std::string_view query, std::string_view plan)>;
virtual void visitQueryPlans(const QueryPlanVisitor& visitor) const = 0;
using Clock = std::chrono::steady_clock;
struct QueryStats
{
std::string_view query;
std::string_view plan;
std::size_t callCount{};
std::chrono::microseconds totalTime{};
std::chrono::microseconds meanTime{};
std::chrono::microseconds stdDevTime{};
};
using QueryVisitor = std::function<void(const QueryStats&)>;
virtual void visitQueries(const QueryVisitor& visitor) const = 0;
};
std::unique_ptr<IQueryPlanRecorder> createQueryPlanRecorder();
std::unique_ptr<IQueryProfiler> createQueryProfiler();
} // namespace lms::db
+1 -1
View File
@@ -27,7 +27,6 @@ add_executable(test-database
TrackArtistLink.cpp
TrackBookmark.cpp
TrackEmbeddedImage.cpp
TrackFeatures.cpp
TrackList.cpp
TrackLyrics.cpp
User.cpp
@@ -36,6 +35,7 @@ add_executable(test-database
target_link_libraries(test-database PRIVATE
lmsdatabase
GTest::GTest
GTest::gtest_main
)
if (NOT CMAKE_CROSSCOMPILING)
-214
View File
@@ -568,81 +568,6 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, MultipleTracksSingleClusterSimilarity)
{
std::list<ScopedTrack> tracks;
ScopedClusterType clusterType{ session, "MyClusterType" };
ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyClusterType" };
for (std::size_t i{}; i < 10; ++i)
{
tracks.emplace_back(session);
{
auto transaction{ session.createWriteTransaction() };
cluster.get().modify()->addTrack(tracks.back().get());
}
}
{
auto transaction{ session.createReadTransaction() };
const auto similarTracks{ Track::findSimilarTrackIds(session, { tracks.front().getId() }) };
EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1);
for (const TrackId similarTrackId : similarTracks.results)
{
EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks));
}
}
}
TEST_F(DatabaseFixture, MultipleTracksMultipleClustersSimilarity)
{
std::list<ScopedTrack> tracks;
ScopedClusterType clusterType{ session, "MyClusterType" };
ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" };
ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" };
for (std::size_t i{}; i < 5; ++i)
{
tracks.emplace_back(session);
{
auto transaction{ session.createWriteTransaction() };
cluster1.get().modify()->addTrack(tracks.back().get());
}
}
for (std::size_t i{ 5 }; i < 10; ++i)
{
tracks.emplace_back(session);
{
auto transaction{ session.createWriteTransaction() };
cluster1.get().modify()->addTrack(tracks.back().get());
cluster2.get().modify()->addTrack(tracks.back().get());
}
}
{
auto transaction{ session.createReadTransaction() };
{
auto similarTracks{ Track::findSimilarTrackIds(session, { tracks.back().getId() }, Range{ 0, 4 }) };
EXPECT_EQ(similarTracks.results.size(), 4);
for (const TrackId similarTrackId : similarTracks.results)
EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 5), std::next(std::cend(tracks), -1), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks));
}
{
auto similarTracks{ Track::findSimilarTrackIds(session, { tracks.front().getId() }) };
EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1);
for (const TrackId similarTrackId : similarTracks.results)
EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks));
}
}
}
TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistSingleCluster)
{
ScopedTrack track{ session };
@@ -798,143 +723,4 @@ namespace lms::db::tests
}
}
TEST_F(DatabaseFixture, MultipleTracksMultipleArtistsMultiClusters)
{
ScopedArtist artist1{ session, "MyArtist1" };
ScopedArtist artist2{ session, "MyArtist2" };
ScopedArtist artist3{ session, "MyArtist3" };
ScopedClusterType clusterType{ session, "MyClusterType" };
ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" };
ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(artist1->findSimilarArtistIds().results.size(), 0);
EXPECT_EQ(artist2->findSimilarArtistIds().results.size(), 0);
EXPECT_EQ(artist3->findSimilarArtistIds().results.size(), 0);
}
std::list<ScopedTrack> tracks;
for (std::size_t i{}; i < 10; ++i)
{
tracks.emplace_back(session);
auto transaction{ session.createWriteTransaction() };
if (i < 5)
session.create<TrackArtistLink>(tracks.back().get(), artist1.get(), TrackArtistLinkType::Artist);
else
{
session.create<TrackArtistLink>(tracks.back().get(), artist2.get(), TrackArtistLinkType::Artist);
cluster2.get().modify()->addTrack(tracks.back().get());
}
cluster1.get().modify()->addTrack(tracks.back().get());
}
tracks.emplace_back(session);
{
auto transaction{ session.createWriteTransaction() };
session.create<TrackArtistLink>(tracks.back().get(), artist3.get(), TrackArtistLinkType::Artist);
cluster2.get().modify()->addTrack(tracks.back().get());
}
{
auto transaction{ session.createReadTransaction() };
{
auto artists{ artist1->findSimilarArtistIds() };
ASSERT_EQ(artists.results.size(), 1);
EXPECT_EQ(artists.results.front(), artist2.getId());
}
{
auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Artist }) };
ASSERT_EQ(artists.results.size(), 1);
EXPECT_EQ(artists.results.front(), artist2.getId());
}
{
auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Lyricist }) };
EXPECT_EQ(artists.results.size(), 0);
}
{
auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Artist, TrackArtistLinkType::Lyricist }) };
ASSERT_EQ(artists.results.size(), 1);
EXPECT_EQ(artists.results.front(), artist2.getId());
}
{
auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Composer }) };
EXPECT_EQ(artists.results.size(), 0);
}
{
auto artists{ artist2->findSimilarArtistIds() };
ASSERT_EQ(artists.results.size(), 2);
EXPECT_EQ(artists.results[0], artist1.getId());
EXPECT_EQ(artists.results[1], artist3.getId());
}
}
}
TEST_F(DatabaseFixture, MultipleTracksMultipleReleasesMultiClusters)
{
ScopedRelease release1{ session, "MyRelease1" };
ScopedRelease release2{ session, "MyRelease2" };
ScopedRelease release3{ session, "MyRelease3" };
ScopedClusterType clusterType{ session, "MyClusterType" };
ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" };
ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(release1->getSimilarReleases().size(), 0);
EXPECT_EQ(release2->getSimilarReleases().size(), 0);
EXPECT_EQ(release3->getSimilarReleases().size(), 0);
}
std::list<ScopedTrack> tracks;
for (std::size_t i{}; i < 10; ++i)
{
tracks.emplace_back(session);
auto transaction{ session.createWriteTransaction() };
if (i < 5)
tracks.back().get().modify()->setRelease(release1.get());
else
{
tracks.back().get().modify()->setRelease(release2.get());
cluster2.get().modify()->addTrack(tracks.back().get());
}
cluster1.get().modify()->addTrack(tracks.back().get());
}
tracks.emplace_back(session);
{
auto transaction{ session.createWriteTransaction() };
tracks.back().get().modify()->setRelease(release3.get());
cluster2.get().modify()->addTrack(tracks.back().get());
}
{
auto transaction{ session.createReadTransaction() };
{
auto releases{ release1->getSimilarReleases() };
ASSERT_EQ(releases.size(), 1);
EXPECT_EQ(releases.front()->getId(), release2.getId());
}
{
auto releases{ release2->getSimilarReleases() };
ASSERT_EQ(releases.size(), 2);
EXPECT_EQ(releases[0]->getId(), release1.getId());
EXPECT_EQ(releases[1]->getId(), release3.getId());
}
}
}
} // namespace lms::db::tests
+1 -3
View File
@@ -36,7 +36,6 @@
#include "database/objects/Track.hpp"
#include "database/objects/TrackArtistLink.hpp"
#include "database/objects/TrackBookmark.hpp"
#include "database/objects/TrackFeatures.hpp"
#include "database/objects/TrackList.hpp"
#include "database/objects/User.hpp"
@@ -142,9 +141,8 @@ namespace lms::db::tests
class DatabaseFixture : public ::testing::Test
{
public:
~DatabaseFixture();
~DatabaseFixture() override;
public:
static void SetUpTestCase();
static void TearDownTestCase();
-6
View File
@@ -102,9 +102,3 @@ namespace lms::db::tests
}
}
} // namespace lms::db::tests
int main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
-49
View File
@@ -1,49 +0,0 @@
/*
* Copyright (C) 2021 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 <http://www.gnu.org/licenses/>.
*/
#include "Common.hpp"
#include "database/objects/TrackFeatures.hpp"
namespace lms::db::tests
{
using ScopedTrackFeatures = ScopedEntity<db::TrackFeatures>;
TEST_F(DatabaseFixture, TrackFeatures)
{
ScopedTrack track{ session };
ScopedUser user{ session, "MyUser" };
{
auto transaction{ session.createReadTransaction() };
EXPECT_EQ(TrackFeatures::getCount(session), 0);
}
ScopedTrackFeatures trackFeatures{ session, track.lockAndGet(), "" };
{
auto transaction{ session.createWriteTransaction() };
EXPECT_EQ(TrackFeatures::getCount(session), 1);
auto allTrackFeatures{ TrackFeatures::find(session) };
ASSERT_EQ(allTrackFeatures.results.size(), 1);
EXPECT_EQ(allTrackFeatures.results.front(), trackFeatures.getId());
}
}
} // namespace lms::db::tests
+14
View File
@@ -0,0 +1,14 @@
add_library(lmsmath INTERFACE)
target_include_directories(lmsmath INTERFACE
include
)
if(BUILD_TESTING)
add_subdirectory(test)
endif()
if (BUILD_BENCHMARKS)
add_subdirectory(bench)
endif()
+19
View File
@@ -0,0 +1,19 @@
add_executable(bench-math
ChamferDistance.cpp
CosineDistance.cpp
DotProduct.cpp
EuclideanDistance.cpp
FFT.cpp
Math.cpp
)
target_link_libraries(bench-math PRIVATE
lmscore
lmsmath
benchmark
)
target_compile_options(bench-math PRIVATE
$<$<NOT:$<CONFIG:Debug>>:-ffast-math>
)
+123
View File
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <cmath>
#include <random>
#include <vector>
#include <benchmark/benchmark.h>
#include "core/Random.hpp"
#include "math/ChamferDistance.hpp"
#include "math/Vector.hpp"
namespace lms::core::benchs
{
template<std::size_t Size>
struct BenchDistance
{
BenchDistance(const math::Vector<Size, float>& ref)
: _ref{ ref } {}
float operator()(const math::Vector<Size, float>& b) const
{
float sum{};
for (std::size_t i{}; i < Size; ++i)
{
const float diff{ _ref[i] - b[i] };
sum += diff * diff;
}
return std::sqrt(sum);
}
const math::Vector<Size, float>& _ref;
};
template<std::size_t VectorSize, std::size_t SetASize, std::size_t SetBSize>
static void BM_ChamferDistanceAtoB(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
std::vector<math::Vector<VectorSize, float>> vecA;
std::vector<math::Vector<VectorSize, float>> vecB;
vecA.reserve(SetASize);
vecB.reserve(SetBSize);
for (std::size_t i{}; i < SetASize; ++i)
{
auto& vec{ vecA.emplace_back() };
core::random::fillContainer(randomEngine, vec, 0.F, 1.F);
}
for (std::size_t i{}; i < SetBSize; ++i)
{
auto& vec{ vecB.emplace_back() };
core::random::fillContainer(randomEngine, vec, 0.F, 1.F);
}
for (auto _ : state)
{
benchmark::DoNotOptimize(math::chamferDistanceAtoB<BenchDistance<VectorSize>>(vecA, vecB));
}
state.SetItemsProcessed(state.iterations() * SetASize * SetBSize);
}
template<std::size_t VectorSize, std::size_t SetASize, std::size_t SetBSize>
static void BM_SymmetricalChamferDistance(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
std::vector<math::Vector<VectorSize, float>> vecA;
std::vector<math::Vector<VectorSize, float>> vecB;
vecA.reserve(SetASize);
vecB.reserve(SetBSize);
for (std::size_t i{}; i < SetASize; ++i)
{
auto& vec{ vecA.emplace_back() };
core::random::fillContainer(randomEngine, vec, 0.F, 1.F);
}
for (std::size_t i{}; i < SetBSize; ++i)
{
auto& vec{ vecB.emplace_back() };
core::random::fillContainer(randomEngine, vec, 0.F, 1.F);
}
for (auto _ : state)
{
benchmark::DoNotOptimize(math::symmetricalChamferDistance<BenchDistance<VectorSize>>(vecA, vecB));
}
state.SetItemsProcessed(state.iterations() * 2 * SetASize * SetBSize);
}
// Benchmarks with different configurations
BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 128, 10, 10);
BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 128, 50, 50);
BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 256, 10, 10);
BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 256, 50, 50);
BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 128, 10, 10);
BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 128, 50, 50);
BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 256, 10, 10);
BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 256, 50, 50);
} // namespace lms::core::benchs
+105
View File
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <benchmark/benchmark.h>
#include <random>
#include "core/Random.hpp"
#include "math/CosineDistance.hpp"
#include "math/NormalizedCosineDistance.hpp"
namespace lms::math::benchs
{
template<std::size_t Size>
static void BM_CosineDistance(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
Vector<Size, float> vec1;
Vector<Size, float> vec2;
core::random::fillContainer(randomEngine, vec1, 0.F, 1.F);
core::random::fillContainer(randomEngine, vec2, 0.F, 1.F);
for (auto _ : state)
{
benchmark::DoNotOptimize(computeCosineDistance(vec1, vec2));
}
state.SetItemsProcessed(state.iterations() * Size);
}
template<std::size_t Size>
static void BM_NormalizedCosineDistance(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
Vector<Size, float> vec1;
Vector<Size, float> vec2;
core::random::fillContainer(randomEngine, vec1, 0.F, 1.F);
core::random::fillContainer(randomEngine, vec2, 0.F, 1.F);
// normalize once, the normalized distance assumes L2-normalized vectors
vec1.normalizeL2();
vec2.normalizeL2();
for (auto _ : state)
{
benchmark::DoNotOptimize(computeNormalizedCosineDistance(vec1, vec2));
}
state.SetItemsProcessed(state.iterations() * Size);
}
template<std::size_t Size>
static void BM_NormalizedCosineDistance_Functor(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
Vector<Size, float> vec1;
Vector<Size, float> vec2;
core::random::fillContainer(randomEngine, vec1, 0.F, 1.F);
core::random::fillContainer(randomEngine, vec2, 0.F, 1.F);
vec1.normalizeL2();
vec2.normalizeL2();
const NormalizedCosineDistance<Size, float> dist{ vec1 };
for (auto _ : state)
{
benchmark::DoNotOptimize(dist(vec2));
}
state.SetItemsProcessed(state.iterations() * Size);
}
BENCHMARK_TEMPLATE(BM_CosineDistance, 4);
BENCHMARK_TEMPLATE(BM_CosineDistance, 50);
BENCHMARK_TEMPLATE(BM_CosineDistance, 160);
BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance, 4);
BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance, 50);
BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance, 160);
BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance_Functor, 4);
BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance_Functor, 50);
BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance_Functor, 160);
} // namespace lms::math::benchs
+51
View File
@@ -0,0 +1,51 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <benchmark/benchmark.h>
#include <random>
#include "core/Random.hpp"
#include "math/DotProduct.hpp"
namespace lms::math::benchs
{
template<std::size_t Size>
static void BM_DotProduct(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
Vector<Size, float> vec1;
Vector<Size, float> vec2;
core::random::fillContainer(randomEngine, vec1, 0.F, 1.F);
core::random::fillContainer(randomEngine, vec2, 0.F, 1.F);
for (auto _ : state)
{
benchmark::DoNotOptimize(computeDotProduct(vec1, vec2));
}
state.SetItemsProcessed(state.iterations() * Size);
}
BENCHMARK_TEMPLATE(BM_DotProduct, 4);
BENCHMARK_TEMPLATE(BM_DotProduct, 50);
BENCHMARK_TEMPLATE(BM_DotProduct, 160);
} // namespace lms::math::benchs
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <benchmark/benchmark.h>
#include <random>
#include "core/Random.hpp"
#include "math/EuclideanDistance.hpp"
namespace lms::core::benchs
{
template<std::size_t Size>
static void BM_SquaredEuclideanDistance(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
math::Vector<Size, float> vec1;
math::Vector<Size, float> vec2;
core::random::fillContainer(randomEngine, vec1, 0.F, 1.F);
core::random::fillContainer(randomEngine, vec2, 0.F, 1.F);
for (auto _ : state)
{
benchmark::DoNotOptimize(math::computeEuclideanSquaredDistance(vec1, vec2));
}
state.SetItemsProcessed(state.iterations() * Size);
}
template<std::size_t Size>
static void BM_SquaredEuclideanDistanceWithWeights(benchmark::State& state)
{
std::minstd_rand randomEngine{ 0 };
math::Vector<Size, float> vec1;
math::Vector<Size, float> vec2;
math::Vector<Size, float> weights;
core::random::fillContainer(randomEngine, vec1, 0.F, 1.F);
core::random::fillContainer(randomEngine, vec2, 0.F, 1.F);
core::random::fillContainer(randomEngine, weights, 0.F, 1.F);
for (auto _ : state)
{
benchmark::DoNotOptimize(math::computeEuclideanSquaredDistanceWithWeights(vec1, vec2, weights));
}
state.SetItemsProcessed(state.iterations() * Size);
}
BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistance, 4);
BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistance, 50);
BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistance, 160);
BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistanceWithWeights, 4);
BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistanceWithWeights, 50);
BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistanceWithWeights, 160);
} // namespace lms::core::benchs
+73
View File
@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <algorithm>
#include <cmath>
#include <complex>
#include <numbers>
#include <vector>
#include <benchmark/benchmark.h>
#include "core/AlignedHeapArray.hpp"
#include "math/FFT.hpp"
namespace lms::math::benchs
{
namespace
{
template<typename FloatType>
std::vector<FloatType> generateTestSignal(std::size_t n)
{
std::vector<FloatType> data(n);
for (std::size_t i{}; i < n; ++i)
data[i] = std::sin(static_cast<FloatType>(2) * std::numbers::pi_v<FloatType> * static_cast<FloatType>(i) / static_cast<FloatType>(n));
return data;
}
} // namespace
template<std::size_t N, typename FloatType>
void BM_FFT(benchmark::State& state)
{
const std::vector<FloatType> inputSignal{ generateTestSignal<FloatType>(N) };
FixedRealFFTPlan<N, FloatType> fft;
core::AlignedHeapArray<FloatType, FixedRealFFTPlan<N>::minBufferAlignment> input{ N };
core::AlignedHeapArray<std::complex<FloatType>, FixedRealFFTPlan<N>::minBufferAlignment> output{ fft.getOutputSize() };
std::copy(inputSignal.begin(), inputSignal.end(), input.begin());
for (auto _ : state)
fft.apply({ input.data(), input.size() }, { output.data(), output.size() });
state.counters["Samples/s"] = benchmark::Counter{ static_cast<double>(N), benchmark::Counter::kIsIterationInvariantRate };
state.counters["FFT/s"] = benchmark::Counter{ 1.0, benchmark::Counter::kIsIterationInvariantRate };
}
BENCHMARK(BM_FFT<512, float>);
BENCHMARK(BM_FFT<1024, float>);
BENCHMARK(BM_FFT<2048, float>);
BENCHMARK(BM_FFT<512, double>);
BENCHMARK(BM_FFT<1024, double>);
BENCHMARK(BM_FFT<2048, double>);
} // namespace lms::math::benchs
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <benchmark/benchmark.h>
BENCHMARK_MAIN();
@@ -0,0 +1,95 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cassert>
#include <span>
#include <type_traits>
namespace lms::math
{
template<typename VectorType>
class CentroidCalculator
{
public:
static_assert(!std::is_const_v<VectorType>);
using value_type = typename VectorType::value_type;
using size_type = std::size_t;
constexpr void add(const VectorType& value)
{
_sum += value;
++_count;
}
template<typename InputIt>
constexpr void add(InputIt first, InputIt last)
{
for (; first != last; ++first)
add(*first);
}
constexpr VectorType finalize() const
{
assert(_count > 0);
VectorType result{ _sum };
result *= static_cast<value_type>(1) / static_cast<value_type>(_count);
return result;
}
constexpr VectorType finalizeNormalized() const
{
VectorType result{ finalize() };
result.normalizeL2();
return result;
}
constexpr bool empty() const
{
return _count == 0;
}
constexpr size_type count() const
{
return _count;
}
private:
VectorType _sum;
size_type _count{};
};
template<typename VectorType>
constexpr VectorType computeCentroid(std::span<const VectorType> values)
{
CentroidCalculator<VectorType> calculator;
calculator.add(std::cbegin(values), std::cend(values));
return calculator.finalize();
}
template<typename VectorType>
constexpr VectorType computeNormalizedCentroid(std::span<const VectorType> values)
{
CentroidCalculator<VectorType> calculator;
calculator.add(std::cbegin(values), std::cend(values));
return calculator.finalizeNormalized();
}
} // namespace lms::math
@@ -0,0 +1,93 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cassert>
#include <concepts>
#include <limits>
#include <ranges>
#include <type_traits>
namespace lms::math
{
/// Computes the Chamfer distance from set A to set B.
/// For each element in A, finds the nearest element in B and sums these minimum distances.
/// The result is normalized by the size of A.
///
/// @tparam DistanceFunc A functor type: constructed with an element of A as ref,
/// then called with each element of B as target.
/// @param A A forward range of vectors
/// @param B A forward range of vectors (same element type as A)
/// @return The normalized sum of minimum distances from A to B
template<typename DistanceFunc,
std::ranges::forward_range RangeA,
std::ranges::forward_range RangeB>
requires std::same_as<std::ranges::range_value_t<RangeA>,
std::ranges::range_value_t<RangeB>>
auto chamferDistanceAtoB(const RangeA& A, const RangeB& B)
{
using Vector = std::ranges::range_value_t<RangeA>;
using ValueType = std::invoke_result_t<DistanceFunc, const Vector&>;
assert(!std::ranges::empty(A));
assert(!std::ranges::empty(B));
ValueType total{};
std::size_t countA{};
for (const auto& a : A)
{
DistanceFunc distFunc{ a };
ValueType bestDist{ std::numeric_limits<ValueType>::max() };
for (const auto& b : B)
{
const ValueType dist{ distFunc(b) };
if (dist < bestDist)
bestDist = dist;
}
total += bestDist;
++countA;
}
return total / static_cast<ValueType>(countA);
}
/// Computes the symmetrical Chamfer distance between two sets.
/// Returns the average of chamferDistanceAtoB(A, B) and chamferDistanceAtoB(B, A).
///
/// @tparam DistanceFunc A functor type: constructed with the ref element,
/// then called with each candidate element.
/// @param A A forward range of vectors
/// @param B A forward range of vectors (same element type as A)
/// @return The symmetrical Chamfer distance
template<typename DistanceFunc,
std::ranges::forward_range RangeA,
std::ranges::forward_range RangeB>
requires std::same_as<std::ranges::range_value_t<RangeA>,
std::ranges::range_value_t<RangeB>>
auto symmetricalChamferDistance(const RangeA& A, const RangeB& B)
{
const auto aToB{ chamferDistanceAtoB<DistanceFunc>(A, B) };
const auto bToA{ chamferDistanceAtoB<DistanceFunc>(B, A) };
return (aToB + bToA) / static_cast<decltype(aToB)>(2);
}
} // namespace lms::math
@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <algorithm>
#include <cmath>
#include "math/Vector.hpp"
namespace lms::math
{
template<std::size_t Size, typename FloatType>
FloatType computeCosineDistance(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b)
{
constexpr FloatType smallEpsilon{ 1e-12F };
FloatType dot{};
FloatType lhsNormSquared{};
FloatType rhsNormSquared{};
for (std::size_t i{}; i < Size; ++i)
{
dot += a[i] * b[i];
lhsNormSquared += a[i] * a[i];
rhsNormSquared += b[i] * b[i];
}
const FloatType denom{ std::sqrt(lhsNormSquared * rhsNormSquared) };
if (denom <= smallEpsilon)
return FloatType{ 1.F };
FloatType cosineSimilarity{ dot / denom };
cosineSimilarity = std::clamp(cosineSimilarity, FloatType{ -1.F }, FloatType{ 1.F });
return FloatType{ 1.F } - cosineSimilarity;
}
template<std::size_t Size, typename FloatType = float>
struct CosineDistance
{
constexpr CosineDistance(const Vector<Size, FloatType>& ref)
: _ref{ ref }
{
}
FloatType operator()(const Vector<Size, FloatType>& a) const
{
return computeCosineDistance(_ref, a);
}
const Vector<Size, FloatType>& _ref;
};
} // namespace lms::math
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <type_traits>
#include "math/SquareMatrix.hpp"
#include "math/Vector.hpp"
namespace lms::math
{
template<std::size_t Size, typename FloatType = float>
class CovarianceMatrixCalculator
{
public:
static_assert(!std::is_const_v<FloatType>);
using CovarianceMatrix = SquareMatrix<FloatType, Size>;
constexpr void add(const Vector<Size, FloatType>& centeredVector)
{
for (std::size_t i{}; i < Size; ++i)
{
for (std::size_t j{}; j <= i; ++j)
_cov[i][j] += centeredVector[i] * centeredVector[j];
}
++_count;
}
template<typename InputIt>
constexpr void add(InputIt first, InputIt last)
{
for (; first != last; ++first)
add(*first);
}
constexpr void finalizeSample(CovarianceMatrix& out) const
{
out.fill(FloatType{});
if (_count < 2)
return;
const FloatType divisor{ static_cast<FloatType>(_count - 1) };
for (std::size_t i{}; i < Size; ++i)
{
for (std::size_t j{}; j <= i; ++j)
{
const FloatType value{ _cov[i][j] / divisor };
out[i][j] = value;
out[j][i] = value;
}
}
}
constexpr void finalizePopulation(CovarianceMatrix& out) const
{
out.fill(FloatType{});
if (_count == 0)
return;
const FloatType divisor{ static_cast<FloatType>(_count) };
for (std::size_t i{}; i < Size; ++i)
{
for (std::size_t j{}; j <= i; ++j)
{
const FloatType value{ _cov[i][j] / divisor };
out[i][j] = value;
out[j][i] = value;
}
}
}
constexpr bool empty() const
{
return _count == 0;
}
constexpr std::size_t count() const
{
return _count;
}
private:
CovarianceMatrix _cov{};
std::size_t _count{};
};
} // namespace lms::math
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "math/Vector.hpp"
namespace lms::math
{
template<std::size_t Size, typename FloatType>
constexpr FloatType computeDotProduct(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b)
{
FloatType res{};
for (std::size_t i{}; i < Size; ++i)
res += a[i] * b[i];
return res;
}
template<std::size_t Size, typename FloatType = float>
struct DotProduct
{
constexpr DotProduct(const Vector<Size, FloatType>& ref)
: _ref{ ref }
{
}
constexpr FloatType operator()(const Vector<Size, FloatType>& a) const
{
return computeDotProduct(_ref, a);
}
const Vector<Size, FloatType>& _ref;
};
} // namespace lms::math
+54
View File
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cmath>
#include <span>
namespace lms::math
{
template<typename FloatType = float>
FloatType entropy(std::span<const FloatType> c)
{
FloatType sum{};
for (auto v : c)
sum += v;
constexpr FloatType epsilon{ 1e-12 };
if (sum <= epsilon)
return {};
const FloatType invSum{ FloatType{ 1 } / sum };
FloatType res{};
for (auto v : c)
{
if (v <= epsilon)
continue;
const FloatType p{ v * invSum };
res -= p * std::log(p);
}
return res;
}
} // namespace lms::math
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "math/Vector.hpp"
namespace lms::math
{
template<std::size_t Size, typename FloatType>
constexpr FloatType computeEuclideanSquaredDistance(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b)
{
FloatType res{};
for (std::size_t i{}; i < Size; ++i)
{
const FloatType diff{ a[i] - b[i] };
res += diff * diff;
}
return res;
}
template<std::size_t Size, typename FloatType>
constexpr FloatType computeEuclideanSquaredDistanceWithWeights(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b, const Vector<Size, FloatType>& weights)
{
FloatType res{};
for (std::size_t i{}; i < Size; ++i)
{
const FloatType diff{ a[i] - b[i] };
res += diff * diff * weights[i];
}
return res;
}
template<std::size_t Size, typename FloatType = float>
struct SquaredEuclideanDistance
{
constexpr SquaredEuclideanDistance(const Vector<Size, FloatType>& ref)
: _ref{ ref } {}
constexpr FloatType operator()(const Vector<Size, FloatType>& a) const
{
return computeEuclideanSquaredDistance(_ref, a);
}
const Vector<Size, FloatType>& _ref;
};
template<std::size_t Size, typename FloatType = float>
struct SquaredEuclideanDistanceWithWeights
{
constexpr SquaredEuclideanDistanceWithWeights(const Vector<Size, FloatType>& ref, const Vector<Size, FloatType>& weights)
: _ref{ ref }
, _weights{ weights }
{
}
constexpr FloatType operator()(const Vector<Size, FloatType>& a) const
{
return computeEuclideanSquaredDistanceWithWeights(_ref, a, _weights);
}
const Vector<Size, FloatType>& _ref;
const Vector<Size, FloatType>& _weights;
};
} // namespace lms::math
+150
View File
@@ -0,0 +1,150 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <algorithm>
#include <array>
#include <bit>
#include <cassert>
#include <cmath>
#include <complex>
#include <cstddef>
#include <numbers>
#include <span>
namespace lms::math
{
template<std::size_t Size, typename FloatType = float>
class FixedRealFFTPlan
{
static_assert(std::has_single_bit(Size), "Size must be power of two");
public:
static constexpr std::size_t minBufferAlignment{ 32 };
constexpr FixedRealFFTPlan()
{
// Twiddles
for (std::size_t k{}; k < halfSize; ++k)
{
FloatType angle{ FloatType(-2) * std::numbers::pi_v<FloatType> * k / Size };
_twiddles[k] = std::complex<FloatType>{ std::cos(angle), std::sin(angle) };
}
// Bit-reversal for halfSize FFT
constexpr std::size_t logHalf{ std::countr_zero(halfSize) };
for (std::size_t i{}; i < halfSize; ++i)
_bitrev[i] = reverseBits(i, logHalf);
}
constexpr static std::size_t getInputSize() noexcept { return Size; }
constexpr static std::size_t getOutputSize() noexcept { return halfSize + 1; }
constexpr void apply(std::span<const FloatType> input, std::span<std::complex<FloatType>> output) const noexcept
{
assert(input.size() == Size);
assert(output.size() == halfSize + 1);
assert(reinterpret_cast<std::uintptr_t>(input.data()) % minBufferAlignment == 0);
assert(reinterpret_cast<std::uintptr_t>(output.data()) % minBufferAlignment == 0);
// Pack real -> complex
alignas(minBufferAlignment) std::array<std::complex<FloatType>, halfSize> data;
for (std::size_t i{}; i < halfSize; ++i)
data[i] = std::complex<FloatType>{ input[2 * i], input[2 * i + 1] };
fft(data);
// Real FFT post-process
output[0] = std::complex<FloatType>{ data[0].real() + data[0].imag(), FloatType{} };
output[halfSize] = std::complex<FloatType>{ data[0].real() - data[0].imag(), FloatType{} };
for (std::size_t k{ 1 }; k <= halfSize / 2; ++k)
{
const auto a{ data[k] };
const auto b{ std::conj(data[(halfSize - k) & (halfSize - 1)]) };
const auto even{ (a + b) * std::complex<FloatType>{ FloatType(0.5), FloatType{} } };
const auto odd{ (a - b) * std::complex<FloatType>{ FloatType{}, FloatType(-0.5) } };
const auto& W{ _twiddles[k] };
const auto t{ W * odd };
output[k] = even + t;
output[halfSize - k] = std::conj(even - t);
}
}
private:
static constexpr std::size_t halfSize{ Size / 2 };
alignas(minBufferAlignment) std::array<std::complex<FloatType>, halfSize> _twiddles{};
std::array<std::size_t, halfSize> _bitrev{};
static constexpr std::size_t reverseBits(std::size_t x, std::size_t bitCount) noexcept
{
std::size_t y{};
for (std::size_t i{}; i < bitCount; ++i)
{
y = (y << 1) | (x & 1);
x >>= 1;
}
return y;
}
constexpr void fft(std::array<std::complex<FloatType>, halfSize>& data) const noexcept
{
// Bit reversal
for (std::size_t i{}; i < halfSize; ++i)
{
const auto j{ _bitrev[i] };
if (i < j)
std::swap(data[i], data[j]);
}
fftStages<1>(data);
}
template<std::size_t Stage>
constexpr void fftStages(std::array<std::complex<FloatType>, halfSize>& data) const noexcept
{
constexpr std::size_t len{ 1U << Stage };
if constexpr (len <= halfSize)
{
constexpr std::size_t half{ len >> 1 };
constexpr std::size_t step{ Size / len };
for (std::size_t i{}; i < halfSize; i += len)
{
for (std::size_t j{}; j < half; ++j)
{
auto& u{ data[i + j] };
auto& v{ data[i + j + half] };
const auto t{ _twiddles[j * step] * v };
v = u - t;
u = u + t;
}
}
fftStages<Stage + 1>(data);
}
}
};
} // namespace lms::math
@@ -0,0 +1,127 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cassert>
#include <limits>
#include <span>
#include <type_traits>
#include <vector>
#include "math/EuclideanDistance.hpp"
namespace lms::math
{
template<typename VectorType>
class MedoidCalculator
{
public:
static_assert(!std::is_const_v<VectorType>);
using value_type = typename VectorType::value_type;
using size_type = std::size_t;
// Add a single vector, returning its index
size_type add(const VectorType& value)
{
_vectors.push_back(value);
return _vectors.size() - 1;
}
// Compute the medoid: the vector with minimum sum of squared distances to all others
// Returns the index of the medoid in the added vectors
size_type findMedoidIndex() const
{
assert(!empty());
size_type medoidIndex{};
value_type minTotalDistance{ std::numeric_limits<value_type>::max() };
for (size_type i{}; i < _vectors.size(); ++i)
{
value_type totalDistance{};
const SquaredEuclideanDistance distFunc{ _vectors[i] };
for (size_type j{}; j < _vectors.size(); ++j)
{
if (i != j)
totalDistance += distFunc(_vectors[j]);
}
if (totalDistance < minTotalDistance)
{
minTotalDistance = totalDistance;
medoidIndex = i;
}
}
return medoidIndex;
}
// Compute the medoid vector itself
VectorType finalize() const
{
return _vectors[findMedoidIndex()];
}
// Get a specific vector by index
const VectorType& getVector(size_type index) const
{
assert(index < _vectors.size());
return _vectors[index];
}
// Query methods
bool empty() const
{
return _vectors.empty();
}
size_type count() const
{
return _vectors.size();
}
void clear()
{
_vectors.clear();
}
private:
std::vector<VectorType> _vectors;
};
template<typename VectorType>
VectorType computeMedoid(std::span<const VectorType> values)
{
MedoidCalculator<VectorType> calculator;
for (const auto& value : values)
calculator.add(value);
return calculator.finalize();
}
template<typename VectorType>
VectorType computeNormalizedMedoid(std::span<const VectorType> values)
{
MedoidCalculator<VectorType> calculator;
for (const auto& value : values)
calculator.add(value);
return calculator.finalizeNormalized();
}
} // namespace lms::math
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cmath>
#include "math/DotProduct.hpp"
#include "math/Vector.hpp"
namespace lms::math
{
// Returns the cosine distance in [0, 1] for L2-normalized vectors:
// 0 = identical direction, 0.5 = orthogonal, 1 = opposite directions.
template<std::size_t Size, typename FloatType>
constexpr FloatType computeNormalizedCosineDistance(
const Vector<Size, FloatType>& a,
const Vector<Size, FloatType>& b)
{
return (FloatType{ 1 } - computeDotProduct(a, b)) / FloatType{ 2 };
}
template<std::size_t Size, typename FloatType = float>
struct NormalizedCosineDistance
{
constexpr NormalizedCosineDistance(const Vector<Size, FloatType>& ref)
: _ref{ ref }
{
}
constexpr FloatType operator()(const Vector<Size, FloatType>& a) const
{
return (FloatType{ 1 } - computeDotProduct(_ref, a)) / FloatType{ 2 };
}
const Vector<Size, FloatType>& _ref;
};
} // namespace lms::math
@@ -0,0 +1,152 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <array>
#include <cmath>
#include <cstddef>
#include <memory>
#include <random>
#include "math/SquareMatrix.hpp"
#include "math/Vector.hpp"
namespace lms::math
{
template<std::size_t Size, typename FloatType>
FloatType dotProduct(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b)
{
FloatType result{};
for (std::size_t i{}; i < Size; ++i)
result += a[i] * b[i];
return result;
}
template<typename FloatType, std::size_t Size>
void computeEigenpairsViaPowerIteration(const SquareMatrix<FloatType, Size>& covariance,
std::array<Vector<Size, FloatType>, Size>& eigenvectors,
Vector<Size, FloatType>& eigenvalues,
std::size_t maxIterations = 200,
FloatType epsilon = static_cast<FloatType>(1e-15))
{
// Power iteration with Deflation for computing eigendecomposition.
// Iteratively finds the largest eigenvalue and corresponding eigenvector,
// then removes it from the matrix and repeats.
auto covarianceCopy{ std::make_unique<SquareMatrix<FloatType, Size>>(covariance) };
std::minstd_rand rng{ 42 };
std::uniform_real_distribution<FloatType> dist{ static_cast<FloatType>(-1.0), static_cast<FloatType>(1.0) };
for (std::size_t k{}; k < Size; ++k)
{
Vector<Size, FloatType> v;
for (std::size_t i{}; i < Size; ++i)
v[i] = dist(rng);
FloatType prevEigenvalue{};
for (std::size_t iter{}; iter < maxIterations; ++iter)
{
Vector<Size, FloatType> Av;
for (std::size_t i{}; i < Size; ++i)
{
for (std::size_t j{}; j < Size; ++j)
Av[i] += (*covarianceCopy)[i][j] * v[j];
}
FloatType normSquared{};
for (std::size_t i{}; i < Size; ++i)
normSquared += Av[i] * Av[i];
if (normSquared < epsilon)
break;
const FloatType norm{ std::sqrt(normSquared) };
for (std::size_t i{}; i < Size; ++i)
v[i] = Av[i] / norm;
eigenvalues[k] = norm;
// Early exit if eigenvalue converged
if (iter > 0 && std::abs(norm - prevEigenvalue) < epsilon)
break;
prevEigenvalue = norm;
}
eigenvectors[k] = v;
// Deflate matrix: A = A - lambda * v * v^T
for (std::size_t i{}; i < Size; ++i)
{
for (std::size_t j{}; j < Size; ++j)
(*covarianceCopy)[i][j] -= eigenvalues[k] * v[i] * v[j];
}
}
}
template<std::size_t BasisCount, std::size_t FeatureCount, typename FloatType>
void projectOntoBasis(const std::array<std::array<FloatType, FeatureCount>, BasisCount>& basis,
const Vector<FeatureCount, FloatType>& centered,
Vector<BasisCount, FloatType>& output,
const std::array<FloatType, BasisCount>& scales)
{
for (std::size_t k{}; k < BasisCount; ++k)
{
FloatType sum{};
for (std::size_t j{}; j < FeatureCount; ++j)
sum += basis[k][j] * centered[j];
output[k] = sum * scales[k];
}
}
template<std::size_t Size, typename FloatType>
FloatType pearsonCorrelation(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b)
{
static_assert(Size > 0);
FloatType meanA{};
FloatType meanB{};
for (std::size_t i{}; i < Size; ++i)
{
meanA += a[i];
meanB += b[i];
}
meanA /= static_cast<FloatType>(Size);
meanB /= static_cast<FloatType>(Size);
FloatType cov{};
FloatType varA{};
FloatType varB{};
for (std::size_t i{}; i < Size; ++i)
{
const FloatType da{ a[i] - meanA };
const FloatType db{ b[i] - meanB };
cov += da * db;
varA += da * da;
varB += db * db;
}
if (varA <= static_cast<FloatType>(0) || varB <= static_cast<FloatType>(0))
return static_cast<FloatType>(0);
return cov / std::sqrt(varA * varB);
}
} // namespace lms::math
+178
View File
@@ -0,0 +1,178 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <algorithm>
#include <array>
#include <cassert>
#include <cmath>
#include <cstddef>
namespace lms::math
{
template<typename T, std::size_t N>
class SquareMatrix
{
public:
static_assert(N > 0, "SquareMatrix size must be positive");
using Row = std::array<T, N>;
constexpr SquareMatrix() = default;
constexpr explicit SquareMatrix(const T& value)
{
fill(value);
}
constexpr void fill(const T& value)
{
for (auto& row : _values)
row.fill(value);
}
constexpr std::size_t size() const noexcept
{
return N;
}
constexpr Row& operator[](std::size_t index)
{
assert(index < N);
return _values[index];
}
constexpr const Row& operator[](std::size_t index) const
{
assert(index < N);
return _values[index];
}
constexpr auto begin()
{
return _values.begin();
}
constexpr auto end()
{
return _values.end();
}
constexpr auto begin() const
{
return _values.begin();
}
constexpr auto end() const
{
return _values.end();
}
constexpr auto cbegin() const
{
return _values.cbegin();
}
constexpr auto cend() const
{
return _values.cend();
}
private:
std::array<Row, N> _values{};
};
template<typename T, std::size_t N>
bool choleskyDecompose(const SquareMatrix<T, N>& A, SquareMatrix<T, N>& L)
{
static_assert(std::is_floating_point_v<T>, "Cholesky decomposition requires floating point type");
L.fill(T{});
for (std::size_t i{}; i < N; ++i)
{
for (std::size_t j{}; j <= i; ++j)
{
T sum{};
for (std::size_t k{}; k < j; ++k)
sum += L[i][k] * L[j][k];
if (i == j)
{
const T val{ A[i][i] - sum };
if (val <= T{})
return false;
L[i][j] = std::sqrt(val);
}
else
{
L[i][j] = (A[i][j] - sum) / L[j][j];
}
}
}
return true;
}
template<typename T, std::size_t N>
void invertLowerTriangular(const SquareMatrix<T, N>& L, SquareMatrix<T, N>& Linv)
{
static_assert(std::is_floating_point_v<T>, "Requires floating point type");
Linv.fill(T{});
for (std::size_t i{}; i < N; ++i)
{
assert(std::abs(L[i][i]) > std::numeric_limits<T>::epsilon());
Linv[i][i] = T{ 1 } / L[i][i];
for (std::size_t j{}; j < i; ++j)
{
T sum{};
for (std::size_t k{ j }; k < i; ++k)
sum += L[i][k] * Linv[k][j];
Linv[i][j] = -sum / L[i][i];
}
}
}
template<typename T, std::size_t N>
T computeSymmetryMaxDiff(const SquareMatrix<T, N>& M)
{
static_assert(std::is_floating_point_v<T>, "Requires floating point type");
T maxDiff{};
for (std::size_t i{}; i < N; ++i)
{
for (std::size_t j{ i + 1 }; j < N; ++j)
{
const T diff{ std::abs(M[i][j] - M[j][i]) };
maxDiff = std::max(maxDiff, diff);
}
}
return maxDiff;
}
} // namespace lms::math
@@ -0,0 +1,102 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cmath>
#include <cstddef>
namespace lms::math
{
template<typename FloatType = float>
class StatsAccumulator
{
public:
constexpr void add(FloatType x);
constexpr std::size_t getCount() const;
constexpr FloatType getMean() const;
constexpr FloatType getSampleStdDev() const;
constexpr FloatType getSampleVariance() const;
constexpr FloatType getPopulationVariance() const;
constexpr FloatType getPopulationStdDev() const;
private:
std::size_t n{};
double mean{};
double M2{};
};
template<typename FloatType>
inline constexpr void StatsAccumulator<FloatType>::add(FloatType x)
{
const double n1{ static_cast<double>(n++) };
const double nn{ static_cast<double>(n) };
const double delta{ static_cast<double>(x) - mean };
const double delta_n{ delta / nn };
const double term1{ delta * delta_n * n1 };
mean += delta_n;
M2 += term1;
}
template<typename FloatType>
inline constexpr std::size_t StatsAccumulator<FloatType>::getCount() const
{
return n;
}
template<typename FloatType>
inline constexpr FloatType StatsAccumulator<FloatType>::getMean() const
{
return static_cast<FloatType>(mean);
}
template<typename FloatType>
inline constexpr FloatType StatsAccumulator<FloatType>::getSampleVariance() const
{
if (n < 2)
return FloatType{};
return static_cast<FloatType>(M2 / (n - 1));
}
template<typename FloatType>
inline constexpr FloatType StatsAccumulator<FloatType>::getPopulationVariance() const
{
if (n < 1)
return FloatType{};
return static_cast<FloatType>(M2 / n);
}
template<typename FloatType>
inline constexpr FloatType StatsAccumulator<FloatType>::getSampleStdDev() const
{
return static_cast<FloatType>(std::sqrt(getSampleVariance()));
}
template<typename FloatType>
inline constexpr FloatType StatsAccumulator<FloatType>::getPopulationStdDev() const
{
return static_cast<FloatType>(std::sqrt(getPopulationVariance()));
}
} // namespace lms::math
+142
View File
@@ -0,0 +1,142 @@
/*
* Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <array>
#include <cmath>
namespace lms::math
{
template<std::size_t Size, typename FloatType = float>
class Vector
{
public:
static_assert(std::is_floating_point_v<FloatType>);
using value_type = FloatType;
using Norm = FloatType;
using Distance = FloatType;
constexpr explicit Vector(value_type initValue = value_type{})
{
_values.fill(initValue);
}
template<typename... Args>
requires(sizeof...(Args) == Size) && (std::convertible_to<Args, value_type> && ...)
constexpr Vector(Args... args)
: _values{ static_cast<value_type>(args)... }
{
}
constexpr static std::size_t getSize() { return Size; }
constexpr value_type* data() { return _values.data(); }
constexpr const value_type* data() const { return _values.data(); }
constexpr value_type& operator[](std::size_t index) { return _values[index]; }
constexpr value_type operator[](std::size_t index) const { return _values[index]; }
constexpr Vector& operator+=(const Vector& other)
{
for (std::size_t i{}; i < Size; ++i)
_values[i] += other[i];
return *this;
}
constexpr Vector& operator-=(const Vector& other)
{
for (std::size_t i{}; i < Size; ++i)
_values[i] -= other[i];
return *this;
}
constexpr Vector& operator*=(value_type factor)
{
for (std::size_t i{}; i < Size; ++i)
_values[i] *= factor;
return *this;
}
Norm computeNorm() const
{
Norm res{};
for (value_type val : _values)
res += val * val;
return std::sqrt(res);
}
void normalizeL2()
{
constexpr value_type smallEpsilon{ 1e-12 };
const Norm n{ computeNorm() };
if (n > smallEpsilon)
{
for (value_type& v : _values)
v /= n;
}
}
auto begin() { return std::begin(_values); }
auto begin() const { return std::begin(_values); }
auto cbegin() const { return std::cbegin(_values); }
auto end() { return std::end(_values); }
auto end() const { return std::end(_values); }
auto cend() const { return std::cend(_values); }
private:
std::array<value_type, Size> _values;
};
template<std::size_t Size, typename FloatType>
constexpr Vector<Size, FloatType> operator+(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b)
{
Vector<Size, FloatType> res{ a };
res += b;
return res;
}
template<std::size_t Size, typename FloatType>
constexpr Vector<Size, FloatType> operator-(const Vector<Size, FloatType>& a, const Vector<Size, FloatType>& b)
{
Vector<Size, FloatType> res{ a };
res -= b;
return res;
}
template<std::size_t Size, typename FloatType>
constexpr Vector<Size, FloatType> operator*(const Vector<Size, FloatType>& v, typename Vector<Size, FloatType>::value_type scalar)
{
Vector<Size, FloatType> res{ v };
res *= scalar;
return res;
}
template<std::size_t Size, typename FloatType>
constexpr Vector<Size, FloatType> operator*(typename Vector<Size, FloatType>::value_type scalar, const Vector<Size, FloatType>& v)
{
return v * scalar;
}
} // namespace lms::math
+72
View File
@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <array>
#include <cassert>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <span>
namespace lms::math
{
template<std::size_t FrameSize, typename FloatType = float>
class HannWindow
{
static_assert(FrameSize > 0, "FrameSize must be greater than zero");
public:
HannWindow()
{
if constexpr (FrameSize == 1)
{
_coefficients[0] = FloatType{ 1 };
_energy = FloatType{ 1 };
return;
}
constexpr auto frameSize{ static_cast<FloatType>(FrameSize) };
for (std::size_t i{}; i < FrameSize; ++i)
{
const auto coefficient{ static_cast<FloatType>(0.5) * (FloatType{ 1 } - std::cos(static_cast<FloatType>(2) * std::numbers::pi_v<FloatType> * static_cast<FloatType>(i) / (frameSize - FloatType{ 1 }))) };
_coefficients[i] = coefficient;
_energy += coefficient * coefficient;
}
}
[[nodiscard]] std::span<const FloatType, FrameSize> values() const noexcept { return _coefficients; }
[[nodiscard]] FloatType energy() const noexcept { return _energy; }
void apply(std::span<const FloatType, FrameSize> input, std::span<FloatType, FrameSize> output) const noexcept
{
assert(input.size() == FrameSize);
assert(output.size() == FrameSize);
for (std::size_t i{}; i < FrameSize; ++i)
output[i] = input[i] * _coefficients[i];
}
private:
std::array<FloatType, FrameSize> _coefficients{};
FloatType _energy{};
};
} // namespace lms::math
+39
View File
@@ -0,0 +1,39 @@
include(GoogleTest)
add_executable(test-math
ChamferDistance.cpp
CentroidCalculator.cpp
CosineDistance.cpp
CovarianceCalculator.cpp
DotProduct.cpp
Entropy.cpp
EuclideanDistance.cpp
FFT.cpp
MedoidCalculator.cpp
NormalizedCosineDistance.cpp
PrincipalComponents.cpp
SquareMatrix.cpp
StatsAccumulator.cpp
Vector.cpp
Window.cpp
)
target_include_directories(test-math PRIVATE
../include
)
target_link_libraries(test-math PRIVATE
lmscore
lmsmath
Threads::Threads
GTest::GTest
GTest::gtest_main
)
target_compile_options(test-math PRIVATE
$<$<NOT:$<CONFIG:Debug>>:-ffast-math>
)
if (NOT CMAKE_CROSSCOMPILING)
gtest_discover_tests(test-math)
endif()
+72
View File
@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "math/CentroidCalculator.hpp"
#include "math/Vector.hpp"
namespace lms::math::centroidCalculatorTests
{
TEST(CentroidCalculator, initialState)
{
CentroidCalculator<Vector<3, float>> calculator;
EXPECT_TRUE(calculator.empty());
EXPECT_EQ(calculator.count(), 0U);
}
TEST(CentroidCalculator, addAndFinalize)
{
CentroidCalculator<Vector<3, float>> calculator;
calculator.add(Vector<3, float>{ 1.0F, 2.0F, 3.0F });
calculator.add(Vector<3, float>{ 4.0F, 5.0F, 6.0F });
const Vector<3, float> result = calculator.finalize();
EXPECT_FLOAT_EQ(result[0], 2.5F);
EXPECT_FLOAT_EQ(result[1], 3.5F);
EXPECT_FLOAT_EQ(result[2], 4.5F);
}
TEST(CentroidCalculator, finalizeNormalized)
{
CentroidCalculator<Vector<2, float>> calculator;
calculator.add(Vector<2, float>{ 3.0F, 4.0F });
const Vector<2, float> result = calculator.finalizeNormalized();
EXPECT_NEAR(result.computeNorm(), 1.0F, 1e-6F);
EXPECT_NEAR(result[0], 0.6F, 1e-6F);
EXPECT_NEAR(result[1], 0.8F, 1e-6F);
}
TEST(CentroidCalculator, computeCentroidSpan)
{
const std::array<Vector<2, float>, 2> values{
Vector<2, float>{ 0.0F, 2.0F },
Vector<2, float>{ 2.0F, 0.0F }
};
const Vector<2, float> result = computeCentroid(std::span<const Vector<2, float>>(values));
EXPECT_FLOAT_EQ(result[0], 1.0F);
EXPECT_FLOAT_EQ(result[1], 1.0F);
}
} // namespace lms::math::centroidCalculatorTests
+137
View File
@@ -0,0 +1,137 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <cmath>
#include <gtest/gtest.h>
#include "math/ChamferDistance.hpp"
#include "math/Vector.hpp"
namespace lms::math::chamferDistanceTests
{
constexpr float epsilon{ 1e-4F };
template<std::size_t Size>
struct SimpleDistance
{
SimpleDistance(const Vector<Size, float>& ref)
: _ref{ ref } {}
float operator()(const Vector<Size, float>& b) const
{
float sum{};
for (std::size_t i{}; i < Size; ++i)
{
const float diff{ _ref[i] - b[i] };
sum += diff * diff;
}
return std::sqrt(sum);
}
const Vector<Size, float>& _ref;
};
TEST(ChamferDistance, singleElementSets)
{
const Vector<2, float> A[]{ { 0.F, 0.F } };
const Vector<2, float> B[]{ { 3.F, 4.F } };
const float result{ chamferDistanceAtoB<SimpleDistance<2>>(A, B) };
const float expected{ 5.F }; // sqrt(3^2 + 4^2) = 5
EXPECT_NEAR(result, expected, epsilon);
}
TEST(ChamferDistance, identicalSets)
{
const Vector<2, float> A[]{ { 1.F, 2.F }, { 3.F, 4.F } };
const float result{ chamferDistanceAtoB<SimpleDistance<2>>(A, A) };
EXPECT_NEAR(result, 0.F, epsilon);
}
TEST(ChamferDistance, asymmetricDistance)
{
// A = {(0,0), (1,0)}, B = {(0,0), (2,0)}
// For a=(0,0): min(dist to (0,0), dist to (2,0)) = 0
// For a=(1,0): min(dist to (0,0), dist to (2,0)) = min(1, 1) = 1
// Average = (0 + 1) / 2 = 0.5
const Vector<2, float> A[]{ { 0.F, 0.F }, { 1.F, 0.F } };
const Vector<2, float> B[]{ { 0.F, 0.F }, { 2.F, 0.F } };
const float result{ chamferDistanceAtoB<SimpleDistance<2>>(A, B) };
EXPECT_NEAR(result, 0.5F, epsilon);
}
TEST(ChamferDistance, symmetricalDistance)
{
const Vector<2, float> A[]{ { 0.F, 0.F }, { 2.F, 0.F } };
const Vector<2, float> B[]{ { 0.F, 0.F }, { 1.F, 0.F } };
const float symDist{ symmetricalChamferDistance<SimpleDistance<2>>(A, B) };
const float aToB{ chamferDistanceAtoB<SimpleDistance<2>>(A, B) };
const float bToA{ chamferDistanceAtoB<SimpleDistance<2>>(B, A) };
const float expected{ (aToB + bToA) / 2.F };
EXPECT_NEAR(symDist, expected, epsilon);
}
TEST(ChamferDistance, largerSets)
{
// A has 3 elements, B has 2 elements
const Vector<2, float> A[]{ { 0.F, 0.F }, { 1.F, 1.F }, { 2.F, 2.F } };
const Vector<2, float> B[]{ { 0.F, 0.F }, { 3.F, 3.F } };
const float result{ chamferDistanceAtoB<SimpleDistance<2>>(A, B) };
// a1: min(0, sqrt(27)) = 0
// a2: min(sqrt(2), sqrt(8)) = sqrt(2)
// a3: min(sqrt(8), sqrt(2)) = sqrt(2)
// Average = (0 + sqrt(2) + sqrt(2)) / 3 = 2*sqrt(2) / 3
const float expected{ 2.F * std::sqrt(2.F) / 3.F };
EXPECT_NEAR(result, expected, epsilon);
}
TEST(ChamferDistance, negativeCoordinates)
{
const Vector<2, float> A[]{ { -1.F, -1.F } };
const Vector<2, float> B[]{ { 1.F, 1.F } };
const float result{ chamferDistanceAtoB<SimpleDistance<2>>(A, B) };
const float expected{ std::sqrt(8.F) }; // sqrt(2^2 + 2^2)
EXPECT_NEAR(result, expected, epsilon);
}
TEST(ChamferDistance, higherDimensions)
{
const Vector<5, float> A[]{ { 1.F, 2.F, 3.F, 4.F, 5.F } };
const Vector<5, float> B[]{ { 1.F, 2.F, 3.F, 4.F, 5.F } };
const float result{ chamferDistanceAtoB<SimpleDistance<5>>(A, B) };
EXPECT_NEAR(result, 0.F, epsilon);
}
} // namespace lms::math::chamferDistanceTests
+76
View File
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "math/CosineDistance.hpp"
namespace lms::math::cosineDistanceTests
{
constexpr float epsilon{ 1e-6F };
TEST(CosineDistance, equalVectors)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ 1.F, 2.F, 3.F };
EXPECT_NEAR(computeCosineDistance(a, b), 0.F, epsilon);
}
TEST(CosineDistance, orthogonalVectors)
{
const Vector<3, float> a{ 1.F, 0.F, 0.F };
const Vector<3, float> b{ 0.F, 1.F, 0.F };
EXPECT_NEAR(computeCosineDistance(a, b), 1.F, epsilon);
}
TEST(CosineDistance, oppositeVectors)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ -1.F, -2.F, -3.F };
EXPECT_NEAR(computeCosineDistance(a, b), 2.F, epsilon);
}
TEST(CosineDistance, zeroNormVector)
{
const Vector<3, float> a{ 0.F, 0.F, 0.F };
const Vector<3, float> b{ 1.F, 2.F, 3.F };
EXPECT_FLOAT_EQ(computeCosineDistance(a, b), 1.F);
}
TEST(CosineDistance, vectorMethod)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ 1.F, 2.F, 3.F };
EXPECT_NEAR(computeCosineDistance(a, b), 0.F, epsilon);
}
TEST(CosineDistance, functor)
{
const Vector<3, float> reference{ 1.F, 0.F, 0.F };
const Vector<3, float> candidate{ 0.F, 1.F, 0.F };
const CosineDistance<3, float> distance{ reference };
EXPECT_NEAR(distance(candidate), 1.F, epsilon);
}
} // namespace lms::math::cosineDistanceTests
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "math/CovarianceCalculator.hpp"
#include "math/SquareMatrix.hpp"
#include "math/Vector.hpp"
namespace lms::math::covarianceCalculatorTests
{
constexpr float epsilon{ 1e-6F };
TEST(CovarianceCalculator, empty)
{
CovarianceMatrixCalculator<2, float> calculator;
EXPECT_TRUE(calculator.empty());
EXPECT_EQ(calculator.count(), 0U);
}
TEST(CovarianceCalculator, sampleCovariance)
{
CovarianceMatrixCalculator<2, float> calculator;
calculator.add({ 1.0F, 0.0F });
calculator.add({ -1.0F, 0.0F });
SquareMatrix<float, 2> covariance;
calculator.finalizeSample(covariance);
EXPECT_NEAR(covariance[0][0], 2.0F, epsilon);
EXPECT_NEAR(covariance[0][1], 0.0F, epsilon);
EXPECT_NEAR(covariance[1][0], 0.0F, epsilon);
EXPECT_NEAR(covariance[1][1], 0.0F, epsilon);
}
TEST(CovarianceCalculator, populationCovariance)
{
CovarianceMatrixCalculator<2, float> calculator;
calculator.add(Vector<2, float>{ 1.0F, 0.0F });
calculator.add(Vector<2, float>{ -1.0F, 0.0F });
SquareMatrix<float, 2> covariance;
calculator.finalizePopulation(covariance);
EXPECT_NEAR(covariance[0][0], 1.0F, epsilon);
EXPECT_NEAR(covariance[0][1], 0.0F, epsilon);
EXPECT_NEAR(covariance[1][0], 0.0F, epsilon);
EXPECT_NEAR(covariance[1][1], 0.0F, epsilon);
}
TEST(CovarianceCalculator, singleValueReturnsZero)
{
CovarianceMatrixCalculator<2, float> calculator;
calculator.add({ 1.0F, 2.0F });
SquareMatrix<float, 2> covariance;
calculator.finalizeSample(covariance);
EXPECT_FLOAT_EQ(covariance[0][0], 0.0F);
EXPECT_FLOAT_EQ(covariance[0][1], 0.0F);
EXPECT_FLOAT_EQ(covariance[1][0], 0.0F);
EXPECT_FLOAT_EQ(covariance[1][1], 0.0F);
}
} // namespace lms::math::covarianceCalculatorTests
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "math/DotProduct.hpp"
namespace lms::math::dotProductTests
{
TEST(DotProduct, zeroLength)
{
const Vector<0, float> a{};
const Vector<0, float> b{};
EXPECT_FLOAT_EQ(computeDotProduct(a, b), 0.F);
}
TEST(DotProduct, simpleValues)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ 4.F, 5.F, 6.F };
EXPECT_FLOAT_EQ(computeDotProduct(a, b), 32.F);
}
TEST(DotProduct, orthogonalVectors)
{
const Vector<3, float> a{ 1.F, 0.F, 0.F };
const Vector<3, float> b{ 0.F, 1.F, 0.F };
EXPECT_FLOAT_EQ(computeDotProduct(a, b), 0.F);
}
TEST(DotProduct, negativeValues)
{
const Vector<3, float> a{ -1.F, 2.F, -3.F };
const Vector<3, float> b{ 4.F, -5.F, 6.F };
EXPECT_FLOAT_EQ(computeDotProduct(a, b), -32.F);
}
TEST(DotProduct, vectorMethod)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ 4.F, 5.F, 6.F };
EXPECT_FLOAT_EQ(computeDotProduct(a, b), 32.F);
}
TEST(DotProduct, functor)
{
const Vector<3, float> reference{ 1.F, 2.F, 3.F };
const Vector<3, float> candidate{ 4.F, 5.F, 6.F };
const DotProduct<3, float> dotProduct{ reference };
EXPECT_FLOAT_EQ(dotProduct(candidate), 32.F);
}
} // namespace lms::math::dotProductTests
+85
View File
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <array>
#include <gtest/gtest.h>
#include "math/Entropy.hpp"
namespace lms::math
{
TEST(EntropyTest, ZeroInput)
{
std::array<float, 12> c{};
const float e{ entropy<float>(c) };
EXPECT_EQ(e, 0.f);
}
TEST(EntropyTest, SingleBinIsZeroEntropy)
{
std::array<float, 12> c{};
c[3] = 1.F;
const float e{ entropy<float>(c) };
EXPECT_FLOAT_EQ(e, 0.F);
}
TEST(EntropyTest, UniformDistributionMaxEntropy)
{
std::array<float, 12> c;
for (auto& v : c)
v = 1.F;
const float e{ entropy<float>(c) };
const float expected{ std::log(12.f) };
EXPECT_FLOAT_EQ(e, expected);
}
TEST(EntropyTest, ScaleInvariance)
{
std::array<float, 12> c{ 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f, 1.1f, 1.2f };
const float a{ entropy<float>(c) };
for (auto& v : c)
v *= 1000.f;
const float b{ entropy<float>(c) };
EXPECT_FLOAT_EQ(a, b);
}
TEST(EntropyTest, MoreSpreadMeansHigherEntropy)
{
std::array<float, 12> tight{};
std::array<float, 12> spread{};
tight[5] = 0.5F;
tight[6] = 0.5F;
spread[2] = 0.3F;
spread[6] = 0.4F;
spread[9] = 0.3F;
EXPECT_GT(entropy<float>(spread), entropy<float>(tight));
}
} // namespace lms::math
+118
View File
@@ -0,0 +1,118 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <limits>
#include <gtest/gtest.h>
#include "math/EuclideanDistance.hpp"
namespace lms::math::euclideanDistanceTests
{
constexpr float epsilon{ 1e-4F };
TEST(EuclideanDistance, zeroLength)
{
const Vector<0, float> a{};
const Vector<0, float> b{};
const Vector<0, float> weights{};
EXPECT_FLOAT_EQ(computeEuclideanSquaredDistance(a, b), 0.F);
EXPECT_FLOAT_EQ(computeEuclideanSquaredDistanceWithWeights(a, b, weights), 0.F);
}
TEST(EuclideanDistance, equalVectors)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ 1.F, 2.F, 3.F };
EXPECT_FLOAT_EQ(computeEuclideanSquaredDistance(a, b), 0.F);
}
TEST(EuclideanDistance, unweightedDistance)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ 4.F, 6.F, 8.F };
const float expected{ 50.F }; // 3^2 + 4^2 + 5^2
EXPECT_NEAR(computeEuclideanSquaredDistance(a, b), expected, epsilon);
}
TEST(EuclideanDistance, weightedDistance)
{
const Vector<3, float> a{ 1.F, 3.F, 5.F };
const Vector<3, float> b{ 2.F, 1.F, 6.F };
const Vector<3, float> weights{ 1.F, 0.5F, 2.F };
const float expected{ 5.F }; // 1*1 + 4*0.5 + 1*2
EXPECT_NEAR(computeEuclideanSquaredDistanceWithWeights(a, b, weights), expected, epsilon);
}
TEST(EuclideanDistance, largeMagnitudeValues)
{
// 1e15^2 * 2 = 2e30, well within the float max (~3.4e38), so no overflow
const float big{ 1e15F };
const Vector<2, float> a{ big, big };
const Vector<2, float> b{ 0.F, 0.F };
const float result{ computeEuclideanSquaredDistance(a, b) };
EXPECT_GT(result, 0.F);
}
TEST(EuclideanDistance, smallMagnitudeValues)
{
// Subnormal inputs; result must stay non-negative
const float tiny{ std::numeric_limits<float>::min() };
const Vector<3, float> a{ tiny, tiny, tiny };
const Vector<3, float> b{ 0.F, 0.F, 0.F };
const float result{ computeEuclideanSquaredDistance(a, b) };
EXPECT_GE(result, 0.F);
}
TEST(EuclideanDistance, negativeValues)
{
// Negative components must produce the same result as their positive mirror
const Vector<3, float> a{ -1.F, -2.F, -3.F };
const Vector<3, float> b{ 1.F, 2.F, 3.F };
const Vector<3, float> aMirror{ 1.F, 2.F, 3.F };
const Vector<3, float> bMirror{ -1.F, -2.F, -3.F };
EXPECT_FLOAT_EQ(
computeEuclideanSquaredDistance(a, b),
computeEuclideanSquaredDistance(aMirror, bMirror));
}
TEST(EuclideanDistance, zeroWeights)
{
const Vector<3, float> a{ 1.F, 2.F, 3.F };
const Vector<3, float> b{ 4.F, 5.F, 6.F };
const Vector<3, float> weights{ 0.F, 0.F, 0.F };
EXPECT_FLOAT_EQ(computeEuclideanSquaredDistanceWithWeights(a, b, weights), 0.F);
}
TEST(EuclideanDistance, singleElement)
{
const Vector<1, float> a{ 3.F };
const Vector<1, float> b{ 7.F };
EXPECT_FLOAT_EQ(computeEuclideanSquaredDistance(a, b), 16.F);
}
} // namespace lms::math::euclideanDistanceTests
+218
View File
@@ -0,0 +1,218 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <algorithm>
#include <cmath>
#include <complex>
#include <numbers>
#include <vector>
#include <gtest/gtest.h>
#include "core/AlignedHeapArray.hpp"
#include "math/FFT.hpp"
#include "math/Window.hpp"
namespace lms::math::fftTests
{
constexpr float epsilon{ 1e-3F };
namespace
{
std::size_t getRealFFTOutputSize(std::size_t inputSize)
{
return inputSize / 2 + 1;
}
std::vector<std::complex<float>> computeRealDFT(const std::vector<float>& input)
{
const std::size_t N{ input.size() };
std::vector<std::complex<float>> output(getRealFFTOutputSize(N));
for (std::size_t k{}; k <= N / 2; ++k)
{
std::complex<double> sum{ 0.0, 0.0 };
for (std::size_t n{}; n < N; ++n)
{
const double angle{ -2.0 * std::numbers::pi_v<double> * static_cast<double>(k) * static_cast<double>(n) / static_cast<double>(N) };
std::complex<double> w{ std::cos(angle), std::sin(angle) };
sum += static_cast<double>(input[n]) * w;
}
output[k] = { static_cast<float>(sum.real()), static_cast<float>(sum.imag()) };
}
return output;
}
} // namespace
TEST(FFT, impulse)
{
constexpr std::size_t N{ 8 };
const std::initializer_list<float> inputSignal{ 1.F, 0.F, 0.F, 0.F, 0.F, 0.F, 0.F, 0.F };
const auto expected{ computeRealDFT(inputSignal) };
FixedRealFFTPlan<N> plan;
core::AlignedHeapArray<float, FixedRealFFTPlan<N>::minBufferAlignment> input{ N };
core::AlignedHeapArray<std::complex<float>, FixedRealFFTPlan<N>::minBufferAlignment> output{ getRealFFTOutputSize(N) };
std::copy(inputSignal.begin(), inputSignal.end(), input.begin());
plan.apply(input, output);
for (std::size_t i{}; i < output.size(); ++i)
{
EXPECT_NEAR(output[i].real(), expected[i].real(), epsilon);
EXPECT_NEAR(output[i].imag(), expected[i].imag(), epsilon);
}
}
TEST(FFT, realForwardMatchesReference)
{
constexpr std::size_t N{ 64 };
std::vector<float> inputSignal(N);
for (std::size_t i{}; i < N; ++i)
{
inputSignal[i] = std::sin(2.F * std::numbers::pi_v<float> * static_cast<float>(i) / static_cast<float>(N))
+ 0.25F * std::sin(6.F * std::numbers::pi_v<float> * static_cast<float>(i) / static_cast<float>(N));
}
const auto expected{ computeRealDFT(inputSignal) };
FixedRealFFTPlan<N> plan;
core::AlignedHeapArray<float, FixedRealFFTPlan<N>::minBufferAlignment> input{ N };
core::AlignedHeapArray<std::complex<float>, FixedRealFFTPlan<N>::minBufferAlignment> output{ getRealFFTOutputSize(N) };
std::copy(inputSignal.begin(), inputSignal.end(), input.begin());
plan.apply({ input.data(), input.size() }, { output.data(), output.size() });
for (std::size_t i{}; i < output.size(); ++i)
{
EXPECT_NEAR(output[i].real(), expected[i].real(), epsilon);
EXPECT_NEAR(output[i].imag(), expected[i].imag(), epsilon);
}
}
TEST(FFT, singleFrequencyBin)
{
constexpr std::size_t N{ 64 };
for (std::size_t k{ 1 }; k < N / 2; ++k)
{
std::vector<float> inputSignal(N);
for (std::size_t n{}; n < N; ++n)
inputSignal[n] = std::sin(2.F * std::numbers::pi_v<float> * static_cast<float>(k) * static_cast<float>(n) / static_cast<float>(N));
const auto expected{ computeRealDFT(inputSignal) };
FixedRealFFTPlan<N> plan;
core::AlignedHeapArray<float, FixedRealFFTPlan<N>::minBufferAlignment> input{ N };
core::AlignedHeapArray<std::complex<float>, FixedRealFFTPlan<N>::minBufferAlignment> output{ getRealFFTOutputSize(N) };
std::copy(inputSignal.begin(), inputSignal.end(), input.begin());
plan.apply({ input.data(), input.size() }, { output.data(), output.size() });
for (std::size_t i{}; i < output.size(); ++i)
{
if (i == k)
EXPECT_GT(std::abs(output[i]), 10.F);
else
EXPECT_NEAR(std::abs(output[i]), std::abs(expected[i]), epsilon);
}
}
}
TEST(FFT, forwardIsUnnormalized)
{
constexpr std::size_t N{ 64 };
FixedRealFFTPlan<N> plan;
core::AlignedHeapArray<float, FixedRealFFTPlan<N>::minBufferAlignment> input{ N };
core::AlignedHeapArray<std::complex<float>, FixedRealFFTPlan<N>::minBufferAlignment> output{ getRealFFTOutputSize(N) };
std::fill(input.begin(), input.end(), 1.F);
plan.apply({ input.data(), input.size() }, { output.data(), output.size() });
EXPECT_NEAR(output[0].real(), static_cast<float>(N), epsilon);
}
TEST(FFT, parseval)
{
constexpr std::size_t N{ 64 };
std::vector<float> inputSignal(N);
for (std::size_t i{}; i < N; ++i)
inputSignal[i] = std::sin(static_cast<float>(i));
float timeEnergy{};
for (const auto value : inputSignal)
timeEnergy += value * value;
FixedRealFFTPlan<N> plan;
core::AlignedHeapArray<float, FixedRealFFTPlan<N>::minBufferAlignment> input{ N };
core::AlignedHeapArray<std::complex<float>, FixedRealFFTPlan<N>::minBufferAlignment> output{ getRealFFTOutputSize(N) };
std::copy(inputSignal.begin(), inputSignal.end(), input.begin());
plan.apply({ input.data(), input.size() }, { output.data(), output.size() });
float freqEnergy{};
freqEnergy += std::norm(output[0]);
freqEnergy += std::norm(output[N / 2]);
for (std::size_t k{ 1 }; k < N / 2; ++k)
freqEnergy += 2.F * std::norm(output[k]);
EXPECT_NEAR(timeEnergy, freqEnergy / static_cast<float>(N), epsilon);
}
TEST(FFT, parsevalWithWindow)
{
constexpr std::size_t N{ 64 };
std::vector<float> inputSignal(N);
for (std::size_t n{}; n < N; ++n)
inputSignal[n] = std::sin(2.F * std::numbers::pi_v<float> * static_cast<float>(n) / static_cast<float>(N));
const math::HannWindow<N, float> window;
const float windowEnergy{ window.energy() };
std::vector<float> windowedInput(N);
window.apply(std::span<const float, N>{ inputSignal.data(), inputSignal.size() },
std::span<float, N>{ windowedInput.data(), windowedInput.size() });
float E_time{};
for (float x : windowedInput)
E_time += x * x;
E_time /= windowEnergy;
FixedRealFFTPlan<N> plan;
core::AlignedHeapArray<float, FixedRealFFTPlan<N>::minBufferAlignment> input{ N };
core::AlignedHeapArray<std::complex<float>, FixedRealFFTPlan<N>::minBufferAlignment> output{ getRealFFTOutputSize(N) };
std::copy(windowedInput.begin(), windowedInput.end(), input.begin());
plan.apply({ input.data(), input.size() }, { output.data(), output.size() });
float E_freq{};
E_freq += std::norm(output[0]);
E_freq += std::norm(output[N / 2]);
for (std::size_t k{ 1 }; k < N / 2; ++k)
E_freq += 2.F * std::norm(output[k]);
E_freq /= (windowEnergy * static_cast<float>(N));
EXPECT_NEAR(E_time, E_freq, epsilon * E_time) << "Time-domain and frequency-domain energy mismatch after windowing";
}
} // namespace lms::math::fftTests
+120
View File
@@ -0,0 +1,120 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "math/MedoidCalculator.hpp"
#include "math/Vector.hpp"
namespace lms::math::medoidCalculatorTests
{
TEST(MedoidCalculator, initialState)
{
MedoidCalculator<Vector<3, float>> calculator;
EXPECT_TRUE(calculator.empty());
EXPECT_EQ(calculator.count(), 0U);
}
TEST(MedoidCalculator, singleVector)
{
MedoidCalculator<Vector<3, float>> calculator;
const Vector<3, float> vec{ 1.0F, 2.0F, 3.0F };
calculator.add(vec);
EXPECT_FALSE(calculator.empty());
EXPECT_EQ(calculator.count(), 1U);
EXPECT_EQ(calculator.findMedoidIndex(), 0U);
const Vector<3, float> result = calculator.finalize();
EXPECT_EQ(result[0], 1.0F);
EXPECT_EQ(result[1], 2.0F);
EXPECT_EQ(result[2], 3.0F);
}
TEST(MedoidCalculator, twoVectors)
{
MedoidCalculator<Vector<2, float>> calculator;
const Vector<2, float> v1{ 0.0F, 0.0F };
const Vector<2, float> v2{ 4.0F, 0.0F };
calculator.add(v1);
calculator.add(v2);
EXPECT_EQ(calculator.count(), 2U);
// Both have equal distance to the other, but first one is returned
const std::size_t medoidIndex = calculator.findMedoidIndex();
EXPECT_TRUE(medoidIndex == 0 || medoidIndex == 1);
}
TEST(MedoidCalculator, threeDifferentVectors)
{
MedoidCalculator<Vector<2, float>> calculator;
// Three points: (0,0), (1,0), (10,0)
// Medoid should be (1,0) as it's closest to the others
calculator.add(Vector<2, float>{ 0.0F, 0.0F });
calculator.add(Vector<2, float>{ 1.0F, 0.0F });
calculator.add(Vector<2, float>{ 10.0F, 0.0F });
const std::size_t medoidIndex = calculator.findMedoidIndex();
EXPECT_EQ(medoidIndex, 1U); // The middle point (1,0) is the medoid
const Vector<2, float> result = calculator.finalize();
EXPECT_FLOAT_EQ(result[0], 1.0F);
EXPECT_FLOAT_EQ(result[1], 0.0F);
}
TEST(MedoidCalculator, computeMedoidSpan)
{
const std::array<Vector<2, float>, 3> values{
Vector<2, float>{ 0.0F, 0.0F },
Vector<2, float>{ 1.0F, 0.0F },
Vector<2, float>{ 10.0F, 0.0F }
};
const Vector<2, float> result = computeMedoid(std::span<const Vector<2, float>>(values));
EXPECT_FLOAT_EQ(result[0], 1.0F);
EXPECT_FLOAT_EQ(result[1], 0.0F);
}
TEST(MedoidCalculator, getVector)
{
MedoidCalculator<Vector<2, float>> calculator;
calculator.add(Vector<2, float>{ 1.0F, 2.0F });
calculator.add(Vector<2, float>{ 3.0F, 4.0F });
const Vector<2, float>& v0 = calculator.getVector(0U);
const Vector<2, float>& v1 = calculator.getVector(1U);
EXPECT_FLOAT_EQ(v0[0], 1.0F);
EXPECT_FLOAT_EQ(v0[1], 2.0F);
EXPECT_FLOAT_EQ(v1[0], 3.0F);
EXPECT_FLOAT_EQ(v1[1], 4.0F);
}
TEST(MedoidCalculator, clear)
{
MedoidCalculator<Vector<2, float>> calculator;
calculator.add(Vector<2, float>{ 1.0F, 2.0F });
EXPECT_EQ(calculator.count(), 1);
calculator.clear();
EXPECT_EQ(calculator.count(), 0);
}
} // namespace lms::math::medoidCalculatorTests
@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 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 <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "math/NormalizedCosineDistance.hpp"
namespace lms::math::normalizedCosineDistanceTests
{
constexpr float epsilon{ 1e-6F };
TEST(NormalizedCosineDistance, equalNormalizedVectors)
{
Vector<3, float> a{ 1.F, 2.F, 3.F };
Vector<3, float> b{ 1.F, 2.F, 3.F };
a.normalizeL2();
b.normalizeL2();
EXPECT_NEAR(computeNormalizedCosineDistance(a, b), 0.F, epsilon);
}
TEST(NormalizedCosineDistance, orthogonalNormalizedVectors)
{
Vector<3, float> a{ 1.F, 0.F, 0.F };
Vector<3, float> b{ 0.F, 1.F, 0.F };
a.normalizeL2();
b.normalizeL2();
EXPECT_NEAR(computeNormalizedCosineDistance(a, b), 0.5F, epsilon);
}
TEST(NormalizedCosineDistance, oppositeNormalizedVectors)
{
Vector<3, float> a{ 1.F, 1.F, 0.F };
Vector<3, float> b{ -1.F, -1.F, 0.F };
a.normalizeL2();
b.normalizeL2();
EXPECT_NEAR(computeNormalizedCosineDistance(a, b), 1.F, epsilon);
}
TEST(NormalizedCosineDistance, functor)
{
Vector<3, float> reference{ 1.F, 0.F, 0.F };
Vector<3, float> candidate{ 0.F, 1.F, 0.F };
reference.normalizeL2();
candidate.normalizeL2();
const NormalizedCosineDistance<3, float> distance{ reference };
EXPECT_NEAR(distance(candidate), 0.5F, epsilon);
}
} // namespace lms::math::normalizedCosineDistanceTests

Some files were not shown because too many files have changed in this diff Show More