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
@@ -1,13 +1,10 @@
add_library(lmsrecommendation STATIC
impl/clusters/ClustersEngine.cpp
impl/features/FeaturesEngineCache.cpp
impl/features/FeaturesEngine.cpp
impl/features/FeaturesDefs.cpp
impl/playlist-constraints/ConsecutiveArtists.cpp
impl/playlist-constraints/ConsecutiveReleases.cpp
impl/playlist-constraints/DuplicateTracks.cpp
impl/PlaylistGeneratorService.cpp
impl/audio-similarity/musicnn/MusicNNEmbeddingEngine.cpp
impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp
impl/track-selection-constraints/SameArtistConstraint.cpp
impl/track-selection-constraints/SameReleaseConstraint.cpp
impl/RecommendationService.cpp
)
@@ -21,9 +18,20 @@ target_include_directories(lmsrecommendation PRIVATE
)
target_link_libraries(lmsrecommendation PRIVATE
lmssom
lmsaudio
lmsmath
)
target_link_libraries(lmsrecommendation PUBLIC
lmscore
lmsdatabase
)
# Should be safe enough for what we're doing
target_compile_options(lmsrecommendation PRIVATE
$<$<NOT:$<CONFIG:Debug>>:-ffast-math>
)
if(BUILD_TESTING)
add_subdirectory(test)
endif()
@@ -19,7 +19,7 @@
#pragma once
#include <memory>
#include <span>
#include "core/EnumSet.hpp"
@@ -39,15 +39,12 @@ namespace lms::recommendation
public:
virtual ~IEngine() = default;
virtual void load(bool forceReload, const ProgressCallback& progressCallback = {}) = 0;
virtual void requestCancelLoad() = 0;
virtual void load() = 0;
virtual TrackContainer findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const = 0;
virtual TrackContainer findSimilarTracks(const std::vector<db::TrackId>& tracksId, std::size_t maxCount) const = 0;
virtual ReleaseContainer getSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const = 0;
virtual ArtistContainer getSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const = 0;
virtual TrackResults findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const = 0;
virtual TrackResults findSimilarTracks(std::span<const db::TrackId> tracksId, std::size_t maxCount) const = 0;
virtual ReleaseResults findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const = 0;
virtual ArtistResults findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const = 0;
virtual TrackResults findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const = 0;
};
std::unique_ptr<IEngine> createEngine(db::IDb& db);
} // namespace lms::recommendation
@@ -1,117 +0,0 @@
/*
* Copyright (C) 2022 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 "PlaylistGeneratorService.hpp"
#include <algorithm>
#include "core/ILogger.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Track.hpp"
#include "services/recommendation/IRecommendationService.hpp"
#include "playlist-constraints/ConsecutiveArtists.hpp"
#include "playlist-constraints/ConsecutiveReleases.hpp"
#include "playlist-constraints/DuplicateTracks.hpp"
namespace lms::recommendation
{
using namespace db;
std::unique_ptr<IPlaylistGeneratorService> createPlaylistGeneratorService(db::IDb& db, IRecommendationService& recommendationService)
{
return std::make_unique<PlaylistGeneratorService>(db, recommendationService);
}
PlaylistGeneratorService::PlaylistGeneratorService(db::IDb& db, IRecommendationService& recommendationService)
: _db{ db }
, _recommendationService{ recommendationService }
{
_constraints.push_back(std::make_unique<PlaylistGeneratorConstraint::ConsecutiveArtists>(_db));
_constraints.push_back(std::make_unique<PlaylistGeneratorConstraint::ConsecutiveReleases>(_db));
_constraints.push_back(std::make_unique<PlaylistGeneratorConstraint::DuplicateTracks>());
}
std::vector<TrackId> PlaylistGeneratorService::extendPlaylist(TrackListId tracklistId, std::size_t maxCount) const
{
LMS_LOG(RECOMMENDATION, DEBUG, "Requested to extend playlist by " << maxCount << " similar tracks");
// supposed to be ordered from most similar to least similar
std::vector<TrackId> similarTracks{ _recommendationService.findSimilarTracks(tracklistId, maxCount * 2) }; // ask for more tracks than we need as it will be easier to respect constraints
const std::vector<TrackId> startingTracks{ getTracksFromTrackList(tracklistId) };
std::vector<TrackId> finalResult = startingTracks;
finalResult.reserve(startingTracks.size() + maxCount);
std::vector<float> scores;
for (std::size_t i{}; i < maxCount; ++i)
{
if (similarTracks.empty())
break;
scores.resize(similarTracks.size(), {});
// select the similar track that has the best score
for (std::size_t trackIndex{}; trackIndex < similarTracks.size(); ++trackIndex)
{
using namespace db::Debug;
finalResult.push_back(similarTracks[trackIndex]);
scores[trackIndex] = 0;
for (const auto& constraint : _constraints)
scores[trackIndex] += constraint->computeScore(finalResult, finalResult.size() - 1);
finalResult.pop_back();
// early exit if we consider we found a track with no constraint violation (since similarTracks sorted from most to least similar)
if (scores[trackIndex] < 0.01)
break;
}
// get the best score
const std::size_t bestScoreIndex{ static_cast<std::size_t>(std::distance(std::cbegin(scores), std::min_element(std::cbegin(scores), std::cend(scores)))) };
finalResult.push_back(similarTracks[bestScoreIndex]);
similarTracks.erase(std::begin(similarTracks) + bestScoreIndex);
}
// for now, just get some more similar tracks
return std::vector(std::cbegin(finalResult) + startingTracks.size(), std::cend(finalResult));
}
TrackContainer PlaylistGeneratorService::getTracksFromTrackList(db::TrackListId tracklistId) const
{
TrackContainer tracks;
Session& dbSession{ _db.getTLSSession() };
auto transaction{ dbSession.createReadTransaction() };
Track::FindParameters params;
params.setTrackList(tracklistId);
params.setSortMethod(TrackSortMethod::TrackList);
for (const TrackId trackId : Track::findIds(dbSession, params).results)
tracks.push_back(trackId);
return tracks;
}
} // namespace lms::recommendation
@@ -19,24 +19,51 @@
#include "RecommendationService.hpp"
#include <vector>
#include <boost/asio/post.hpp>
#include "audio/IMusicNNEmbeddingExtractor.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/ScanSettings.hpp"
#include "ClustersEngineCreator.hpp"
#include "FeaturesEngineCreator.hpp"
#include "audio-similarity/musicnn/MusicNNEmbeddingEngine.hpp"
#include "clusters/ClustersEngine.hpp"
namespace lms::recommendation
{
namespace
{
db::ScanSettings::SimilarityEngineType getSimilarityEngineType(db::Session& session)
db::ScanSettings::RecommendationEngineType getRecommendationEngineType(db::Session& session)
{
auto transaction{ session.createReadTransaction() };
return db::ScanSettings::find(session)->getRecommendationEngineType();
}
return db::ScanSettings::find(session)->getSimilarityEngineType();
EngineType toEngineType(db::ScanSettings::RecommendationEngineType type)
{
switch (type)
{
case db::ScanSettings::RecommendationEngineType::None:
return EngineType::None;
case db::ScanSettings::RecommendationEngineType::Clusters:
return EngineType::Clusters;
case db::ScanSettings::RecommendationEngineType::AudioSimilarity:
return EngineType::AudioSimilarity;
}
return EngineType::None;
}
std::unique_ptr<IEngine> createEngine(db::ScanSettings::RecommendationEngineType type, db::IDb& db)
{
switch (type)
{
case db::ScanSettings::RecommendationEngineType::Clusters:
return std::make_unique<ClusterEngine>(db);
case db::ScanSettings::RecommendationEngineType::AudioSimilarity:
return std::make_unique<MusicNNEmbeddingEngine>(db);
case db::ScanSettings::RecommendationEngineType::None:
return nullptr;
}
return nullptr;
}
} // namespace
@@ -47,75 +74,104 @@ namespace lms::recommendation
RecommendationService::RecommendationService(db::IDb& db)
: _db{ db }
, _ioContextRunner{ _ioContext, 1, "RecommendationEngine" }
{
load();
requestReload();
}
TrackContainer RecommendationService::findSimilarTracks(db::TrackListId trackListId, std::size_t maxCount) const
TrackResults RecommendationService::findSimilarTracks(db::TrackListId trackListId, std::size_t maxCount) const
{
TrackContainer res;
if (!_engine)
return res;
std::shared_lock lock{ _mutex, std::try_to_lock };
if (!lock || !_engine)
return {};
return _engine->findSimilarTracksFromTrackList(trackListId, maxCount);
}
TrackContainer RecommendationService::findSimilarTracks(const std::vector<db::TrackId>& trackIds, std::size_t maxCount) const
TrackResults RecommendationService::findSimilarTracks(std::span<const db::TrackId> trackIds, std::size_t maxCount) const
{
TrackContainer res;
if (!_engine)
return res;
std::shared_lock lock{ _mutex, std::try_to_lock };
if (!lock || !_engine)
return {};
return _engine->findSimilarTracks(trackIds, maxCount);
}
ReleaseContainer RecommendationService::getSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const
ReleaseResults RecommendationService::findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const
{
ReleaseContainer res;
std::shared_lock lock{ _mutex, std::try_to_lock };
if (!lock || !_engine)
return {};
if (!_engine)
return res;
return _engine->getSimilarReleases(releaseId, maxCount);
;
return _engine->findSimilarReleases(releaseId, maxCount);
}
ArtistContainer RecommendationService::getSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const
ArtistResults RecommendationService::findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const
{
ArtistContainer res;
std::shared_lock lock{ _mutex, std::try_to_lock };
if (!lock || !_engine)
return {};
if (!_engine)
return res;
return _engine->getSimilarArtists(artistId, linkTypes, maxCount);
return res;
return _engine->findSimilarArtists(artistId, linkTypes, maxCount);
}
void RecommendationService::load()
TrackResults RecommendationService::findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const
{
using namespace db;
std::shared_lock lock{ _mutex, std::try_to_lock };
if (!lock || !_engine)
return {};
switch (getSimilarityEngineType(_db.getTLSSession()))
return _engine->findTrackSimilarityPath(startTrackId, endTrackId, maxCount);
}
bool RecommendationService::isEngineTypeSupported(EngineType type) const
{
switch (type)
{
case ScanSettings::SimilarityEngineType::Clusters:
if (_engineType != EngineType::Clusters)
{
_engineType = EngineType::Clusters;
_engine = createClustersEngine(_db);
}
break;
case EngineType::AudioSimilarity:
return audio::canExtractMusicNNEmbeddings();
case ScanSettings::SimilarityEngineType::Features:
case ScanSettings::SimilarityEngineType::None:
_engineType.reset();
_engine.reset();
break;
case EngineType::None:
case EngineType::Clusters:
return true;
}
if (_engine)
_engine->load(false);
return false;
}
db::ScanSettings::RecommendationEngineType RecommendationService::prepareReload()
{
const auto type{ getRecommendationEngineType(_db.getTLSSession()) };
std::unique_lock lock{ _mutex };
_engineType = toEngineType(type);
_engine.reset();
return type;
}
EngineType RecommendationService::getEngineType() const
{
std::shared_lock lock{ _mutex };
return _engineType;
}
void RecommendationService::requestReload()
{
const auto type{ prepareReload() };
boost::asio::post(_ioContext, [this, type] {
auto newEngine{ createEngine(type, _db) };
if (!newEngine)
return;
newEngine->load();
std::unique_lock lock{ _mutex };
_engine = std::move(newEngine);
});
}
bool RecommendationService::isLoaded() const
{
std::shared_lock lock{ _mutex, std::try_to_lock };
return lock && _engine != nullptr;
}
} // namespace lms::recommendation
@@ -19,8 +19,14 @@
#pragma once
#include <optional>
#include <memory>
#include <shared_mutex>
#include <boost/asio/io_context.hpp>
#include "core/IOContextRunner.hpp"
#include "database/objects/ScanSettings.hpp"
#include "services/recommendation/IRecommendationService.hpp"
#include "IEngine.hpp"
@@ -32,12 +38,6 @@ namespace lms::db
namespace lms::recommendation
{
enum class EngineType
{
Clusters,
Features,
};
class RecommendationService : public IRecommendationService
{
public:
@@ -47,20 +47,26 @@ namespace lms::recommendation
RecommendationService& operator=(const RecommendationService&) = delete;
private:
void load() override;
bool isEngineTypeSupported(EngineType type) const override;
TrackContainer findSimilarTracks(db::TrackListId tracklistId, std::size_t maxCount) const override;
TrackContainer findSimilarTracks(const std::vector<db::TrackId>& trackIds, std::size_t maxCount) const override;
ReleaseContainer getSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const override;
ArtistContainer getSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const override;
void requestReload() override;
bool isLoaded() const override;
EngineType getEngineType() const override;
void setEnginePriorities(const std::vector<EngineType>& engineTypes);
void clearEngines();
void loadPendingEngine(EngineType engineType, std::unique_ptr<IEngine> engine, bool forceReload, const ProgressCallback& progressCallback);
TrackResults findSimilarTracks(db::TrackListId tracklistId, std::size_t maxCount) const override;
TrackResults findSimilarTracks(std::span<const db::TrackId> trackIds, std::size_t maxCount) const override;
ReleaseResults findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const override;
ArtistResults findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const override;
TrackResults findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const override;
db::ScanSettings::RecommendationEngineType prepareReload();
db::IDb& _db;
std::optional<EngineType> _engineType;
mutable std::shared_mutex _mutex;
EngineType _engineType{ EngineType::None };
std::unique_ptr<IEngine> _engine;
boost::asio::io_context _ioContext;
core::IOContextRunner _ioContextRunner;
};
} // namespace lms::recommendation
@@ -0,0 +1,98 @@
/*
* 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 <functional>
#include <unordered_map>
#include <vector>
#include "database/Object.hpp"
#include "database/objects/ArtistId.hpp"
#include "database/objects/ReleaseId.hpp"
#include "database/objects/TrackId.hpp"
#include "math/Vector.hpp"
#include "AudioVectorProvider.hpp"
#include "IEngine.hpp"
#include "Types.hpp"
#include "track-selection-constraints/TrackCandidateEvaluator.hpp"
#include "track-selection-constraints/TrackMetadata.hpp"
namespace lms::recommendation
{
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
class AudioSimilarityEngine : public IEngine
{
public:
AudioSimilarityEngine(db::IDb& db);
~AudioSimilarityEngine() override;
AudioSimilarityEngine(const AudioSimilarityEngine&) = delete;
AudioSimilarityEngine& operator=(const AudioSimilarityEngine&) = delete;
private:
using SourceVector = typename Provider::Vector;
using ReducedVector = math::Vector<ReducedDimCount, FloatType>;
static inline constexpr std::size_t SourceDimCount{ SourceVector::getSize() };
void load() override;
TrackResults findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const override;
TrackResults findSimilarTracks(std::span<const db::TrackId> tracksId, std::size_t maxCount) const override;
TrackResults findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const override;
ReleaseResults findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const override;
ArtistResults findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const override;
void initializeConstraints();
void computeDatasetStats();
void computeReducedFeatures();
void computeTrackDistanceThreshold();
void computeReleaseDistanceThreshold();
void computeArtistDistanceThreshold();
void getReducedVector(const SourceVector& sourceVector, ReducedVector& output) const;
void projectToReduced(const SourceVector& sourceVectorCentered, ReducedVector& output) const;
db::IDb& _db;
// Stats, used to normalize input data
std::size_t _trackCount{};
SourceVector _sourceMeans;
// PCA basis: top pcaDimCount eigenvectors (rows) and whitening scales
std::array<std::array<FloatType, SourceDimCount>, ReducedDimCount> _pcaBasis{};
std::array<FloatType, ReducedDimCount> _pcaScale{};
bool _pcaReady{};
// In-memory cache of reduced feature vectors
std::vector<ReducedVector> _vectors;
std::unordered_map<db::TrackId, const ReducedVector*> _trackVectors;
std::unordered_map<db::ReleaseId, std::vector<std::reference_wrapper<const ReducedVector>>> _releaseVectors;
std::unordered_map<db::ArtistId, std::vector<std::reference_wrapper<const ReducedVector>>> _artistVectors;
TrackMetadataMap _trackMetadata;
FloatType _trackDistanceThreshold{};
FloatType _releaseDistanceThreshold{};
FloatType _artistDistanceThreshold{};
TrackCandidateEvaluator _similarityEvaluator;
TrackCandidateEvaluator _pathEvaluator;
};
} // namespace lms::recommendation
@@ -0,0 +1,806 @@
/*
* 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 "AudioSimilarityEngine.hpp"
#include <algorithm>
#include <array>
#include <memory>
#include <random>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
#include "core/Random.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Artist.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/ReleaseArtistLink.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackArtistLink.hpp"
#include "database/objects/TrackList.hpp"
#include "database/objects/TrackMusicNNEmbeddings.hpp"
#include "math/ChamferDistance.hpp"
#include "math/CovarianceCalculator.hpp"
#include "math/MedoidCalculator.hpp"
#include "math/NormalizedCosineDistance.hpp"
#include "math/PrincipalComponents.hpp"
#include "math/StatsAccumulator.hpp"
#include "track-selection-constraints/DuplicateTrackConstraint.hpp"
#include "track-selection-constraints/InterpolationFitConstraint.hpp"
#include "track-selection-constraints/MaxDistanceConstraint.hpp"
#include "track-selection-constraints/SameArtistConstraint.hpp"
#include "track-selection-constraints/SameReleaseConstraint.hpp"
#include "track-selection-constraints/SmoothTransitionConstraint.hpp"
#include "Types.hpp"
#define LOG(sev, message) LMS_LOG(RECOMMENDATION, sev, "[audio-similarity] " << message)
namespace lms::recommendation
{
namespace detail
{
template<typename ReducedVector>
TrackResults findNearestNeighbors(
const ReducedVector& queryVector, // expected to be normalized
const std::unordered_map<db::TrackId, const ReducedVector*>& trackVectors,
std::size_t maxNeighbors,
db::TrackId excludeTrackId)
{
const math::NormalizedCosineDistance distFunc{ queryVector };
TrackResults neighbors;
neighbors.reserve(trackVectors.size());
for (const auto& [trackId, trackVector] : trackVectors)
{
if (trackId == excludeTrackId)
continue;
neighbors.push_back({ .id = trackId, .distance = distFunc(*trackVector) });
}
maxNeighbors = std::min(maxNeighbors, neighbors.size());
if (maxNeighbors == 0)
return {};
std::nth_element(neighbors.begin(), neighbors.begin() + static_cast<std::ptrdiff_t>(maxNeighbors), neighbors.end(), [](const auto& lhs, const auto& rhs) {
return lhs.distance < rhs.distance;
});
neighbors.resize(maxNeighbors);
std::sort(neighbors.begin(), neighbors.end(), [](const auto& lhs, const auto& rhs) {
return lhs.distance < rhs.distance;
});
return neighbors;
}
} // namespace detail
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
AudioSimilarityEngine<Provider, ReducedDimCount>::AudioSimilarityEngine(db::IDb& db)
: _db{ db }
{
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
AudioSimilarityEngine<Provider, ReducedDimCount>::~AudioSimilarityEngine() = default;
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::initializeConstraints()
{
constexpr float interpolationFitWeight{ 0.8F };
constexpr float smoothTransitionWeight{ 0.2F };
constexpr float sameReleaseWeight{ 0.5F };
constexpr float sameArtistWeight{ 0.5F };
_similarityEvaluator = {};
_similarityEvaluator.addHardConstraint(std::make_unique<DuplicateTrackConstraint>());
_similarityEvaluator.addHardConstraint(std::make_unique<MaxDistanceConstraint>(_trackDistanceThreshold));
_similarityEvaluator.addSoftConstraint(std::make_unique<InterpolationFitConstraint>(), interpolationFitWeight);
_similarityEvaluator.addSoftConstraint(std::make_unique<SmoothTransitionConstraint>(), smoothTransitionWeight);
_similarityEvaluator.addSoftConstraint(std::make_unique<SameReleaseConstraint>(_trackMetadata), sameReleaseWeight);
_similarityEvaluator.addSoftConstraint(std::make_unique<SameArtistConstraint>(_trackMetadata), sameArtistWeight);
_pathEvaluator = {};
_pathEvaluator.addHardConstraint(std::make_unique<DuplicateTrackConstraint>());
_pathEvaluator.addSoftConstraint(std::make_unique<InterpolationFitConstraint>(), interpolationFitWeight);
_pathEvaluator.addSoftConstraint(std::make_unique<SmoothTransitionConstraint>(), smoothTransitionWeight);
_pathEvaluator.addSoftConstraint(std::make_unique<SameReleaseConstraint>(_trackMetadata), sameReleaseWeight);
_pathEvaluator.addSoftConstraint(std::make_unique<SameArtistConstraint>(_trackMetadata), sameArtistWeight);
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
TrackResults AudioSimilarityEngine<Provider, ReducedDimCount>::findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar tracks from tracklist");
if (maxCount == 0)
return {};
std::vector<db::TrackId> trackIds;
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
const db::TrackList::pointer trackList{ db::TrackList::find(session, tracklistId) };
if (!trackList)
return {};
trackIds = trackList->getTrackIds();
}
if (trackIds.empty())
return {};
return findSimilarTracks(trackIds, maxCount);
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
TrackResults AudioSimilarityEngine<Provider, ReducedDimCount>::findSimilarTracks(std::span<const db::TrackId> tracksId, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar tracks");
TrackResults res;
if (maxCount == 0 || tracksId.empty())
return res;
math::MedoidCalculator<ReducedVector> medoidCalculator;
for (const db::TrackId trackId : tracksId)
{
const auto it{ _trackVectors.find(trackId) };
if (it == _trackVectors.cend())
continue;
medoidCalculator.add(*it->second);
}
if (medoidCalculator.empty())
return res;
const ReducedVector queryVector{ medoidCalculator.finalize() };
const math::NormalizedCosineDistance distFunc{ queryVector };
using Distance = float;
std::vector<std::pair<db::TrackId, Distance>> rankedTracks;
rankedTracks.reserve(_trackVectors.size());
for (const auto& [trackId, vectors] : _trackVectors)
{
if (std::find(std::cbegin(tracksId), std::cend(tracksId), trackId) != std::cend(tracksId))
continue;
rankedTracks.emplace_back(trackId, distFunc(*vectors));
}
// Oversample to give the diversity selection enough candidates to work with
static constexpr std::size_t oversamplingFactor{ 5 };
const std::size_t candidateCount{ std::min(maxCount * oversamplingFactor, rankedTracks.size()) };
std::partial_sort(std::begin(rankedTracks), std::next(std::begin(rankedTracks), static_cast<std::ptrdiff_t>(candidateCount)), std::end(rankedTracks), [](const auto& lhs, const auto& rhs) {
return lhs.second < rhs.second;
});
rankedTracks.resize(candidateCount);
// Greedy selection: at each step pick the candidate with the lowest penalized score.
// distanceToPrevious is the cosine distance to the last selected track, so that
// SmoothTransitionConstraint penalises large acoustic jumps between consecutive results.
// Pre-seed selectedTracks with the input tracks so that soft constraints (same release,
// same artist) treat them as already taken, preventing the first results from being
// from the same release/artist as the inputs.
std::vector<db::TrackId> selectedTracks(std::cbegin(tracksId), std::cend(tracksId));
selectedTracks.reserve(selectedTracks.size() + maxCount);
res.reserve(maxCount);
const ReducedVector* previousVector{};
while (res.size() < maxCount && !rankedTracks.empty())
{
std::optional<std::size_t> bestIdx;
float bestScore{ std::numeric_limits<float>::max() };
for (std::size_t i{}; i < rankedTracks.size(); ++i)
{
const auto& [candidateId, distanceToQuery]{ rankedTracks[i] };
const ReducedVector* candidateVector{ _trackVectors.at(candidateId) };
const float distanceToPrevious{ previousVector ? math::NormalizedCosineDistance{ *previousVector }(*candidateVector) : 0.F };
const TrackCandidateContext context{
.candidateTrackId = candidateId,
.selectedTracks = selectedTracks,
.distanceToQuery = distanceToQuery,
.distanceToPrevious = distanceToPrevious,
};
if (_similarityEvaluator.rejects(context))
continue;
const float score{ _similarityEvaluator.score(context) };
if (score < bestScore)
{
bestScore = score;
bestIdx = i;
}
}
if (!bestIdx)
break;
const auto& [selectedId, distanceToQuery]{ rankedTracks[*bestIdx] };
res.push_back({ .id = selectedId, .distance = distanceToQuery });
selectedTracks.push_back(selectedId);
previousVector = _trackVectors.at(selectedId);
rankedTracks.erase(std::begin(rankedTracks) + static_cast<std::ptrdiff_t>(*bestIdx));
}
return res;
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
TrackResults AudioSimilarityEngine<Provider, ReducedDimCount>::findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find track similarity path");
if (maxCount == 0)
return {};
const auto itStart{ _trackVectors.find(startTrackId) };
const auto itEnd{ _trackVectors.find(endTrackId) };
if (itStart == _trackVectors.cend() || itEnd == _trackVectors.cend())
return {};
const ReducedVector startVector{ *itStart->second };
const ReducedVector endVector{ *itEnd->second };
const ReducedVector direction{ endVector - startVector };
std::vector<db::TrackId> path;
path.reserve(maxCount);
path.push_back(startTrackId);
const ReducedVector* previousVector{ itStart->second };
static constexpr std::size_t DefaultNeighborCount{ 16 };
static constexpr std::size_t BroadNeighborCount{ 64 };
std::size_t neighborCount{ DefaultNeighborCount };
const std::size_t interiorCount{ (maxCount > 2) ? (maxCount - 2) : 0 };
auto evaluateCandidates = [&](const TrackResults& neighborList) -> std::optional<db::TrackId> {
std::optional<db::TrackId> best;
float bestScore{ std::numeric_limits<float>::max() };
for (const auto& [candidateId, candidateDistance] : neighborList)
{
const auto* candidateVector{ _trackVectors.at(candidateId) };
const float transitionDistance{ math::NormalizedCosineDistance{ *previousVector }(*candidateVector) };
const TrackCandidateContext context{
.candidateTrackId = candidateId,
.selectedTracks = path,
.distanceToQuery = candidateDistance,
.distanceToPrevious = transitionDistance,
};
if (_pathEvaluator.rejects(context))
continue;
const float score{ _pathEvaluator.score(context) };
if (score < bestScore)
{
bestScore = score;
best = candidateId;
}
}
return best;
};
for (std::size_t i{}; i < interiorCount; ++i)
{
const float t{ static_cast<float>(i + 1) / static_cast<float>(interiorCount + 1) };
auto queryPoint{ startVector + direction * t };
queryPoint.normalizeL2();
const auto neighbors{ detail::findNearestNeighbors(queryPoint, _trackVectors, neighborCount, endTrackId) };
std::optional<db::TrackId> bestCandidate{ evaluateCandidates(neighbors) };
if (!bestCandidate && neighborCount < BroadNeighborCount)
{
neighborCount = BroadNeighborCount;
const auto broaderNeighbors{ detail::findNearestNeighbors(queryPoint, _trackVectors, neighborCount, endTrackId) };
bestCandidate = evaluateCandidates(broaderNeighbors);
}
if (!bestCandidate)
continue;
path.push_back(*bestCandidate);
previousVector = _trackVectors.at(*bestCandidate);
}
if (maxCount > 1)
path.push_back(endTrackId);
TrackResults results;
results.reserve(path.size());
const math::NormalizedCosineDistance startDistFunc{ startVector };
for (const db::TrackId trackId : path)
{
const auto* trackVector{ _trackVectors.at(trackId) };
results.push_back({ .id = trackId, .distance = startDistFunc(*trackVector) });
}
return results;
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
ReleaseResults AudioSimilarityEngine<Provider, ReducedDimCount>::findSimilarReleases(
db::ReleaseId releaseId,
std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar releases");
ResultContainer<db::ReleaseId> res;
if (maxCount == 0)
return res;
const auto itQueryRelease{ _releaseVectors.find(releaseId) };
if (itQueryRelease == _releaseVectors.cend() || itQueryRelease->second.empty())
return res;
const auto& queryReleaseFeatures{ itQueryRelease->second };
using Distance = float;
std::vector<std::pair<db::ReleaseId, Distance>> rankedReleases;
rankedReleases.reserve(_releaseVectors.size());
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>;
for (const auto& [candidateId, candidateReleaseVectors] : _releaseVectors)
{
if (candidateId == releaseId || candidateReleaseVectors.empty())
continue;
const FloatType distance{ math::symmetricalChamferDistance<CosineDistance>(
queryReleaseFeatures,
candidateReleaseVectors) };
if (distance <= _releaseDistanceThreshold)
rankedReleases.emplace_back(candidateId, distance);
}
const std::size_t resultCount{ std::min(maxCount, rankedReleases.size()) };
std::partial_sort(std::begin(rankedReleases), std::next(std::begin(rankedReleases), resultCount), std::end(rankedReleases), [](const auto& lhs, const auto& rhs) {
return lhs.second < rhs.second;
});
res.reserve(resultCount);
for (std::size_t i{}; i < resultCount; ++i)
res.push_back({ .id = rankedReleases[i].first, .distance = rankedReleases[i].second });
return res;
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
ArtistResults AudioSimilarityEngine<Provider, ReducedDimCount>::findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar artists");
ArtistResults res;
if (maxCount == 0)
return res;
if (!linkTypes.contains(db::TrackArtistLinkType::Artist))
return res;
const auto itQueryArtist{ _artistVectors.find(artistId) };
if (itQueryArtist == _artistVectors.cend() || itQueryArtist->second.empty())
return res;
const auto& queryArtistFeatures{ itQueryArtist->second };
using Distance = float;
std::vector<std::pair<db::ArtistId, Distance>> rankedArtists;
rankedArtists.reserve(_artistVectors.size());
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), typename ReducedVector::value_type>;
for (const auto& [candidateId, candidateArtistFeatures] : _artistVectors)
{
if (candidateId == artistId || candidateArtistFeatures.empty())
continue;
const FloatType distance{ math::symmetricalChamferDistance<CosineDistance>(
queryArtistFeatures,
candidateArtistFeatures) };
if (distance <= _artistDistanceThreshold)
rankedArtists.emplace_back(candidateId, distance);
}
const std::size_t resultCount{ std::min(maxCount, rankedArtists.size()) };
std::partial_sort(std::begin(rankedArtists), std::next(std::begin(rankedArtists), resultCount), std::end(rankedArtists), [](const auto& lhs, const auto& rhs) {
return lhs.second < rhs.second;
});
res.reserve(resultCount);
for (std::size_t i{}; i < resultCount; ++i)
res.push_back({ .id = rankedArtists[i].first, .distance = rankedArtists[i].second });
return res;
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::load()
{
LMS_SCOPED_TRACE_OVERVIEW("AudioSimilarityEngine", "Loading");
LOG(INFO, "loading...");
computeDatasetStats();
computeReducedFeatures();
computeTrackDistanceThreshold();
computeReleaseDistanceThreshold();
computeArtistDistanceThreshold();
initializeConstraints();
LOG(INFO, "loading complete!");
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::computeDatasetStats()
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Compute dataset stats");
LOG(DEBUG, "computing dataset stats...");
_pcaReady = false;
_trackCount = 0;
std::array<math::StatsAccumulator<FloatType>, SourceDimCount> statsAccumulators;
{
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
Provider::visitVectors(session, [&]([[maybe_unused]] db::TrackId trackId, const SourceVector& sourceVector) {
for (std::size_t i{}; i < SourceDimCount; ++i)
statsAccumulators[i].add(sourceVector[i]);
_trackCount++;
});
}
for (std::size_t featureIndex{}; featureIndex < SourceDimCount; ++featureIndex)
_sourceMeans[featureIndex] = static_cast<FloatType>(statsAccumulators[featureIndex].getMean());
// Compute covariance
using AudioFeatureMatrix = math::SquareMatrix<FloatType, SourceDimCount>;
const auto covariance{ std::make_unique<AudioFeatureMatrix>() };
{
const auto calculator{ std::make_unique<math::CovarianceMatrixCalculator<SourceDimCount, FloatType>>() };
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
Provider::visitVectors(session, [&](db::TrackId, SourceVector& sourceVector) {
for (std::size_t i{}; i < SourceDimCount; ++i)
sourceVector[i] -= _sourceMeans[i];
calculator->add(sourceVector);
});
calculator->finalizeSample(*covariance);
}
// PCA via power iteration + deflation in double precision
{
using EigenMatrix = math::SquareMatrix<double, SourceDimCount>;
using EigenVector = math::Vector<SourceDimCount, double>;
auto covarianceCopy{ std::make_unique<EigenMatrix>() };
for (std::size_t i{}; i < SourceDimCount; ++i)
{
for (std::size_t j{}; j < SourceDimCount; ++j)
(*covarianceCopy)[i][j] = static_cast<double>((*covariance)[i][j]);
}
EigenVector eigenValues{};
auto eigenVectors{ std::make_unique<std::array<EigenVector, SourceDimCount>>() };
math::computeEigenpairsViaPowerIteration(*covarianceCopy, *eigenVectors, eigenValues);
// Store PCA basis and whitening scales
for (std::size_t k{}; k < ReducedDimCount; ++k)
{
for (std::size_t j{}; j < SourceDimCount; ++j)
_pcaBasis[k][j] = static_cast<FloatType>((*eigenVectors)[k][j]);
_pcaScale[k] = (eigenValues[k] > 1e-15) ? static_cast<FloatType>(1.0 / std::sqrt(eigenValues[k])) : FloatType{};
}
}
_pcaReady = true;
LOG(DEBUG, "computing dataset stats done");
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::getReducedVector(const SourceVector& sourceVector, ReducedVector& reducedVector) const
{
SourceVector centeredSourceVector{ sourceVector };
for (std::size_t i{}; i < SourceDimCount; ++i)
centeredSourceVector[i] -= _sourceMeans[i];
projectToReduced(centeredSourceVector, reducedVector);
reducedVector.normalizeL2();
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::projectToReduced(const SourceVector& sourceVectorCentered, ReducedVector& reducedVector) const
{
assert(_pcaReady);
math::projectOntoBasis(_pcaBasis, sourceVectorCentered, reducedVector, _pcaScale);
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::computeReducedFeatures()
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeReducedVectors");
LOG(INFO, "computing reduced vectors... Reducing from " << SourceDimCount << " to " << ReducedDimCount << " dimensions");
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
_trackVectors.clear();
_vectors.clear();
_vectors.reserve(_trackCount); // must keep pointers valid
_releaseVectors.clear();
_artistVectors.clear();
_trackMetadata.clear();
Provider::visitVectors(session, [&](db::TrackId trackId, const SourceVector& sourceVector) {
if (_vectors.size() >= _trackCount)
return; // more tracks appeared since computeDatasetStats(); skip to avoid reallocation (a further reload will include them)
auto& reducedVector{ _vectors.emplace_back() };
getReducedVector(sourceVector, reducedVector);
_trackVectors.try_emplace(trackId, &reducedVector);
});
db::Release::find(session, db::Release::FindParameters{}, [&](const db::Release::pointer& release) {
std::vector<std::reference_wrapper<const ReducedVector>> releaseTrackFeatures;
db::Track::FindParameters params;
params.setRelease(release->getId());
const auto trackIds{ db::Track::findIds(session, params) };
for (const db::TrackId trackId : trackIds.results)
{
const auto itFeatures{ _trackVectors.find(trackId) };
if (itFeatures != std::cend(_trackVectors))
{
assert(itFeatures->second);
releaseTrackFeatures.emplace_back(*itFeatures->second);
_trackMetadata[trackId].releaseId = release->getId();
}
}
if (!releaseTrackFeatures.empty())
_releaseVectors.try_emplace(release->getId(), std::move(releaseTrackFeatures));
});
db::Artist::find(session, db::Artist::FindParameters{}, [&](const db::Artist::pointer& artist) {
std::unordered_set<db::TrackId> artistTrackIds;
// Track-level artists
{
db::Track::FindParameters params;
params.setArtist(artist->getId(), { db::TrackArtistLinkType::Artist });
for (const db::TrackId trackId : db::Track::findIds(session, params).results)
artistTrackIds.insert(trackId);
}
// Album-level artists
{
db::Release::FindParameters params;
params.setArtist(artist->getId());
for (const db::ReleaseId releaseId : db::Release::findIds(session, params).results)
{
if (_releaseVectors.contains(releaseId))
{
db::Track::FindParameters trackParams;
trackParams.setRelease(releaseId);
for (const db::TrackId trackId : db::Track::findIds(session, trackParams).results)
artistTrackIds.insert(trackId);
}
}
}
// Build vectors from deduplicated track IDs
std::vector<std::reference_wrapper<const ReducedVector>> artistTrackVectors;
artistTrackVectors.reserve(artistTrackIds.size());
for (const db::TrackId trackId : artistTrackIds)
{
const auto it{ _trackVectors.find(trackId) };
if (it != std::cend(_trackVectors))
{
assert(it->second);
artistTrackVectors.emplace_back(*it->second);
_trackMetadata[trackId].artistIds.push_back(artist->getId());
}
}
if (!artistTrackVectors.empty())
_artistVectors.try_emplace(artist->getId(), std::move(artistTrackVectors));
});
// Sort artistIds in each TrackMetadata entry for set-intersection in SameArtistConstraint
for (auto& [trackId, metadata] : _trackMetadata)
std::sort(metadata.artistIds.begin(), metadata.artistIds.end());
LOG(INFO, "computed reduced vectors: " << _trackVectors.size() << " tracks, " << _releaseVectors.size() << " releases, " << _artistVectors.size() << " artists");
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::computeTrackDistanceThreshold()
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "computeTrackDistanceThreshold");
constexpr std::size_t maxSampleCount{ 500 };
constexpr float stdDevMultiplier{ 2.F };
const std::size_t sampleCount{ std::min(_trackVectors.size(), maxSampleCount) };
LOG(INFO, "computing track distance threshold using " << sampleCount << " samples...");
// Collect all vector pointers and shuffle for an unbiased random sample.
std::vector<const ReducedVector*> allVectors;
allVectors.reserve(_trackVectors.size());
for (const auto& [id, vec] : _trackVectors)
allVectors.push_back(vec);
std::minstd_rand randomEngine{ 42 };
core::random::shuffleContainer(randomEngine, allVectors);
math::StatsAccumulator<FloatType> stats;
for (std::size_t i{}; i < sampleCount; ++i)
{
const ReducedVector* queryVector{ allVectors[i] };
const math::NormalizedCosineDistance distFunc{ *queryVector };
FloatType minDist{ std::numeric_limits<FloatType>::max() };
for (const ReducedVector* candidateVector : allVectors)
{
if (candidateVector == queryVector)
continue;
const FloatType d{ distFunc(*candidateVector) };
if (d < minDist)
minDist = d;
}
stats.add(minDist);
}
if (stats.getCount() >= 2)
_trackDistanceThreshold = stats.getMean() + stdDevMultiplier * stats.getSampleStdDev();
else
_trackDistanceThreshold = std::numeric_limits<FloatType>::max();
LOG(INFO, "track distance threshold = " << _trackDistanceThreshold);
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::computeReleaseDistanceThreshold()
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeReleaseDistanceThreshold");
constexpr std::size_t maxSampleCount{ 200 };
constexpr float stdDevMultiplier{ 2.F };
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>;
std::vector<const std::vector<std::reference_wrapper<const ReducedVector>>*> allProfiles;
allProfiles.reserve(_releaseVectors.size());
for (const auto& [id, vecs] : _releaseVectors)
allProfiles.push_back(&vecs);
const std::size_t sampleCount{ std::min(allProfiles.size(), maxSampleCount) };
LOG(INFO, "computing release distance threshold using " << sampleCount << " samples...");
std::minstd_rand randomEngine{ 42 };
core::random::shuffleContainer(randomEngine, allProfiles);
math::StatsAccumulator<FloatType> stats;
for (std::size_t i{}; i < sampleCount; ++i)
{
FloatType minDist{ std::numeric_limits<FloatType>::max() };
for (const auto* candidate : allProfiles)
{
if (candidate == allProfiles[i])
continue;
const FloatType d{ math::symmetricalChamferDistance<CosineDistance>(*allProfiles[i], *candidate) };
if (d < minDist)
minDist = d;
}
if (minDist < std::numeric_limits<FloatType>::max())
stats.add(minDist);
}
if (stats.getCount() >= 2)
_releaseDistanceThreshold = stats.getMean() + stdDevMultiplier * stats.getSampleStdDev();
else
_releaseDistanceThreshold = std::numeric_limits<FloatType>::max();
LOG(INFO, "release distance threshold = " << _releaseDistanceThreshold);
}
template<AudioVectorProvider Provider, std::size_t ReducedDimCount>
void AudioSimilarityEngine<Provider, ReducedDimCount>::computeArtistDistanceThreshold()
{
LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeArtistDistanceThreshold");
constexpr std::size_t maxSampleCount{ 200 };
constexpr float stdDevMultiplier{ 2.F };
using CosineDistance = math::NormalizedCosineDistance<ReducedVector::getSize(), FloatType>;
std::vector<const std::vector<std::reference_wrapper<const ReducedVector>>*> allProfiles;
allProfiles.reserve(_artistVectors.size());
for (const auto& [id, vecs] : _artistVectors)
allProfiles.push_back(&vecs);
const std::size_t sampleCount{ std::min(allProfiles.size(), maxSampleCount) };
LOG(INFO, "computing artist distance threshold using " << sampleCount << " samples...");
std::minstd_rand randomEngine{ 42 };
core::random::shuffleContainer(randomEngine, allProfiles);
math::StatsAccumulator<FloatType> stats;
for (std::size_t i{}; i < sampleCount; ++i)
{
FloatType minDist{ std::numeric_limits<FloatType>::max() };
for (const auto* candidate : allProfiles)
{
if (candidate == allProfiles[i])
continue;
const FloatType d{ math::symmetricalChamferDistance<CosineDistance>(*allProfiles[i], *candidate) };
if (d < minDist)
minDist = d;
}
if (minDist < std::numeric_limits<FloatType>::max())
stats.add(minDist);
}
if (stats.getCount() >= 2)
_artistDistanceThreshold = stats.getMean() + stdDevMultiplier * stats.getSampleStdDev();
else
_artistDistanceThreshold = std::numeric_limits<FloatType>::max();
LOG(INFO, "artist distance threshold = " << _artistDistanceThreshold);
}
} // namespace lms::recommendation
#undef LOG
@@ -0,0 +1,38 @@
/*
* 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 "database/objects/TrackId.hpp"
namespace lms::db
{
class Session;
}
namespace lms::recommendation
{
template<typename T>
concept AudioVectorProvider = requires(const T provider, db::Session& session, db::TrackId id, typename T::Vector& v) {
typename T::Vector;
{ provider.getVector(session, id, v) } -> std::same_as<bool>;
provider.visitVectors(session, [](db::TrackId, typename T::Vector&) {});
};
} // namespace lms::recommendation
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,15 +19,7 @@
#pragma once
#include <memory>
namespace lms::db
{
class IDb;
}
namespace lms::recommendation
{
class IEngine;
std::unique_ptr<IEngine> createClustersEngine(db::IDb& db);
using FloatType = float;
} // namespace lms::recommendation
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -17,18 +17,11 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "audio-similarity/AudioSimilarityEngine.impl.hpp"
#include <memory>
#include "IEngine.hpp"
namespace lms::db
{
class IDb;
}
#include "MusicNNEmbeddingEngine.hpp"
namespace lms::recommendation
{
std::unique_ptr<IEngine> createFeaturesEngine(db::IDb& db);
template class AudioSimilarityEngine<MusicNNEmbeddingProvider, 60>;
}
@@ -0,0 +1,29 @@
/*
* 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-similarity/AudioSimilarityEngine.hpp"
#include "MusicNNEmbeddingProvider.hpp"
namespace lms::recommendation
{
using MusicNNEmbeddingEngine = AudioSimilarityEngine<MusicNNEmbeddingProvider, 60>;
}
@@ -0,0 +1,67 @@
/*
* 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 "MusicNNEmbeddingProvider.hpp"
#include "audio/MusicNNEmbeddings.hpp"
#include "database/Session.hpp"
#include "database/objects/TrackMusicNNEmbeddings.hpp"
namespace lms::recommendation
{
namespace
{
void readEmbeddings(const db::ObjectPtr<db::TrackMusicNNEmbeddings>& dbEmbeddings, MusicNNEmbeddingProvider::Vector& vec)
{
static_assert(MusicNNEmbeddingProvider::Vector::getSize() == MusicNNEmbeddingProvider::DimCount);
audio::TrackMusicNNEmbeddings embeddings{};
audio::trackMusicNNEmbeddingsFromBlob(dbEmbeddings->getData(), embeddings);
std::size_t outputIndex{};
for (float val : embeddings.mean.values)
vec[outputIndex++] = val;
assert(outputIndex == MusicNNEmbeddingProvider::DimCount);
}
} // namespace
bool MusicNNEmbeddingProvider::getVector(db::Session& session, db::TrackId trackId, Vector& vec)
{
session.checkReadTransaction();
const db::TrackMusicNNEmbeddings::pointer embeddings{ db::TrackMusicNNEmbeddings::find(session, trackId) };
if (embeddings)
readEmbeddings(embeddings, vec);
return embeddings;
}
void MusicNNEmbeddingProvider::visitVectors(db::Session& session, const std::function<void(db::TrackId, Vector&)>& visitor)
{
session.checkReadTransaction();
Vector vec;
db::TrackMusicNNEmbeddings::find(session, [&](const db::TrackMusicNNEmbeddings::pointer& embeddings) {
readEmbeddings(embeddings, vec);
visitor(embeddings->getTrackId(), vec);
});
}
} // namespace lms::recommendation
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2019 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,27 +19,29 @@
#pragma once
#include <memory>
#include <functional>
#include "database/objects/TrackListId.hpp"
#include "services/recommendation/Types.hpp"
#include "database/objects/TrackId.hpp"
#include "math/Vector.hpp"
#include "audio-similarity/Types.hpp"
namespace lms::db
{
class IDb;
class Session;
}
namespace lms::recommendation
{
class IRecommendationService;
class IPlaylistGeneratorService
class MusicNNEmbeddingProvider
{
public:
virtual ~IPlaylistGeneratorService() = default;
static constexpr std::size_t DimCount{ 200 };
using Vector = math::Vector<DimCount, FloatType>;
// extend an existing playlist with similar tracks (but use playlist contraints)
virtual TrackContainer extendPlaylist(db::TrackListId tracklistId, std::size_t maxCount) const = 0;
static std::size_t getCount(db::Session& session);
static bool getVector(db::Session& session, db::TrackId trackId, Vector& vec);
static void visitVectors(db::Session& session, const std::function<void(db::TrackId, Vector&)>& visitor);
};
std::unique_ptr<IPlaylistGeneratorService> createPlaylistGeneratorService(db::IDb& db, IRecommendationService& recommendationService);
} // namespace lms::recommendation
@@ -19,6 +19,13 @@
#include "ClustersEngine.hpp"
#include <algorithm>
#include <limits>
#include <memory>
#include <optional>
#include <random>
#include <unordered_set>
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Artist.hpp"
@@ -27,85 +34,356 @@
#include "database/objects/Track.hpp"
#include "database/objects/TrackList.hpp"
#include "core/ILogger.hpp"
#include "core/ITraceLogger.hpp"
#include "track-selection-constraints/DuplicateTrackConstraint.hpp"
#include "track-selection-constraints/SameArtistConstraint.hpp"
#include "track-selection-constraints/SameReleaseConstraint.hpp"
#include "track-selection-constraints/TrackCandidateContext.hpp"
#define LOG(sev, message) LMS_LOG(RECOMMENDATION, sev, "[clusters] " << message)
namespace lms::recommendation
{
using namespace db;
namespace
{
template<typename IdType>
std::vector<std::pair<IdType, std::size_t>> computeClusterOverlap(
const std::unordered_map<IdType, std::vector<db::ClusterId>>& profileMap,
const std::unordered_set<IdType>& excludeIds,
const std::unordered_set<db::ClusterId>& queryClusters)
{
std::vector<std::pair<IdType, std::size_t>> results;
for (const auto& [candidateId, candidateClusters] : profileMap)
{
if (excludeIds.contains(candidateId))
continue;
std::size_t count{};
for (const db::ClusterId clusterId : candidateClusters)
if (queryClusters.contains(clusterId))
++count;
if (count > 0)
results.emplace_back(candidateId, count);
}
return results;
}
template<typename IdType>
ResultContainer<IdType> findSimilarByClusterOverlap(
const std::unordered_map<IdType, std::vector<db::ClusterId>>& profileMap,
IdType queryId,
const std::vector<db::ClusterId>& queryClusters,
std::size_t maxCount)
{
const std::unordered_set<db::ClusterId> querySet{ queryClusters.cbegin(), queryClusters.cend() };
auto overlapCounts{ computeClusterOverlap(profileMap, { queryId }, querySet) };
const std::size_t resultCount{ std::min(maxCount, overlapCounts.size()) };
std::partial_sort(overlapCounts.begin(), std::next(overlapCounts.begin(), resultCount), overlapCounts.end(),
[](const auto& a, const auto& b) { return a.second > b.second; });
ResultContainer<IdType> res;
res.reserve(resultCount);
for (std::size_t i{}; i < resultCount; ++i)
res.push_back({ .id = overlapCounts[i].first, .distance = {} });
return res;
}
} // namespace
std::unique_ptr<IEngine> createClustersEngine(db::IDb& db)
{
return std::make_unique<ClusterEngine>(db);
}
TrackContainer ClusterEngine::findSimilarTracks(const std::vector<TrackId>& trackIds, std::size_t maxCount) const
ClusterEngine::ClusterEngine(db::IDb& db)
: _db{ db }
{
constexpr float sameReleaseWeight{ 0.5F };
constexpr float sameArtistWeight{ 0.5F };
_trackEvaluator.addHardConstraint(std::make_unique<DuplicateTrackConstraint>());
_trackEvaluator.addSoftConstraint(std::make_unique<SameReleaseConstraint>(_trackMetadata), sameReleaseWeight);
_trackEvaluator.addSoftConstraint(std::make_unique<SameArtistConstraint>(_trackMetadata), sameArtistWeight);
}
ClusterEngine::~ClusterEngine() = default;
void ClusterEngine::load()
{
LMS_SCOPED_TRACE_OVERVIEW("ClustersEngine", "Loading");
LOG(INFO, "loading...");
_trackMetadata.clear();
_trackClusters.clear();
_releaseClusters.clear();
_artistClusters.clear();
db::Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
buildTrackMetadata(session);
buildTrackClusters(session);
buildReleaseClusters();
buildArtistClusters();
LOG(INFO, "loaded " << _trackClusters.size() << " tracks, " << _releaseClusters.size() << " releases, " << _artistClusters.size() << " artists");
}
void ClusterEngine::buildTrackMetadata(db::Session& session)
{
LOG(DEBUG, "building track metadata...");
db::Release::find(session, db::Release::FindParameters{}, [&](const db::Release::pointer& release) {
db::Track::FindParameters params;
params.setRelease(release->getId());
for (const db::TrackId trackId : db::Track::findIds(session, params).results)
_trackMetadata[trackId].releaseId = release->getId();
});
db::Artist::find(session, db::Artist::FindParameters{}, [&](const db::Artist::pointer& artist) {
std::unordered_set<db::TrackId> artistTrackIds;
{
db::Track::FindParameters params;
params.setArtist(artist->getId(), { db::TrackArtistLinkType::Artist });
for (const db::TrackId trackId : db::Track::findIds(session, params).results)
artistTrackIds.insert(trackId);
}
{
db::Release::FindParameters params;
params.setArtist(artist->getId());
for (const db::ReleaseId releaseId : db::Release::findIds(session, params).results)
{
db::Track::FindParameters trackParams;
trackParams.setRelease(releaseId);
for (const db::TrackId trackId : db::Track::findIds(session, trackParams).results)
artistTrackIds.insert(trackId);
}
}
for (const db::TrackId trackId : artistTrackIds)
_trackMetadata[trackId].artistIds.push_back(artist->getId());
});
for (auto& [trackId, metadata] : _trackMetadata)
std::sort(metadata.artistIds.begin(), metadata.artistIds.end());
}
void ClusterEngine::buildTrackClusters(db::Session& session)
{
LOG(DEBUG, "building track clusters...");
db::Cluster::find(session, db::Cluster::FindParameters{}, [&](const db::Cluster::pointer& cluster) {
const db::ClusterId clusterId{ cluster->getId() };
for (const db::TrackId trackId : cluster->getTracks().results)
_trackClusters[trackId].push_back(clusterId);
});
}
void ClusterEngine::buildReleaseClusters()
{
LOG(DEBUG, "building release clusters...");
for (const auto& [trackId, clusters] : _trackClusters)
{
const auto metaIt{ _trackMetadata.find(trackId) };
if (metaIt == _trackMetadata.cend())
continue;
if (const db::ReleaseId releaseId{ metaIt->second.releaseId }; releaseId.isValid())
for (const db::ClusterId clusterId : clusters)
_releaseClusters[releaseId].push_back(clusterId);
}
for (auto& [_, clusters] : _releaseClusters)
{
std::sort(clusters.begin(), clusters.end());
clusters.erase(std::unique(clusters.begin(), clusters.end()), clusters.end());
}
}
void ClusterEngine::buildArtistClusters()
{
LOG(DEBUG, "building artist clusters...");
for (const auto& [trackId, clusters] : _trackClusters)
{
const auto metaIt{ _trackMetadata.find(trackId) };
if (metaIt == _trackMetadata.cend())
continue;
for (const db::ArtistId artistId : metaIt->second.artistIds)
for (const db::ClusterId clusterId : clusters)
_artistClusters[artistId].push_back(clusterId);
}
for (auto& [_, clusters] : _artistClusters)
{
std::sort(clusters.begin(), clusters.end());
clusters.erase(std::unique(clusters.begin(), clusters.end()), clusters.end());
}
}
TrackResults ClusterEngine::findSimilarTracks(std::span<const db::TrackId> trackIds, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("ClustersEngine", "Find similar tracks");
if (maxCount == 0 || trackIds.empty())
return {};
std::unordered_set<db::ClusterId> queryClusters;
for (const db::TrackId trackId : trackIds)
{
const auto it{ _trackClusters.find(trackId) };
if (it != _trackClusters.cend())
for (const db::ClusterId clusterId : it->second)
queryClusters.insert(clusterId);
}
if (queryClusters.empty())
return {};
const std::unordered_set<db::TrackId> excludeSet{ std::cbegin(trackIds), std::cend(trackIds) };
auto overlapCounts{ computeClusterOverlap(_trackClusters, excludeSet, queryClusters) };
static constexpr std::size_t oversamplingFactor{ 5 };
const std::size_t candidateCount{ std::min(maxCount * oversamplingFactor, overlapCounts.size()) };
std::partial_sort(overlapCounts.begin(), std::next(overlapCounts.begin(), candidateCount), overlapCounts.end(),
[](const auto& a, const auto& b) { return a.second > b.second; });
overlapCounts.resize(candidateCount);
std::vector<db::TrackId> candidates;
candidates.reserve(candidateCount);
for (const auto& [trackId, count] : overlapCounts)
candidates.push_back(trackId);
std::vector<db::TrackId> seeds{ std::cbegin(trackIds), std::cend(trackIds) };
return greedySelect(std::move(candidates), std::move(seeds), maxCount);
}
TrackResults ClusterEngine::greedySelect(std::vector<db::TrackId> candidates, std::vector<db::TrackId> selectedTracks, std::size_t maxCount) const
{
selectedTracks.reserve(selectedTracks.size() + maxCount);
TrackResults res;
res.reserve(maxCount);
while (res.size() < maxCount && !candidates.empty())
{
std::optional<std::size_t> bestIdx;
float bestScore{ std::numeric_limits<float>::max() };
for (std::size_t i{}; i < candidates.size(); ++i)
{
const TrackCandidateContext context{
.candidateTrackId = candidates[i],
.selectedTracks = selectedTracks,
};
if (_trackEvaluator.rejects(context))
continue;
const float score{ _trackEvaluator.score(context) };
if (score < bestScore)
{
bestScore = score;
bestIdx = i;
}
}
if (!bestIdx)
break;
res.push_back({ .id = candidates[*bestIdx], .distance = {} });
selectedTracks.push_back(candidates[*bestIdx]);
candidates.erase(std::begin(candidates) + static_cast<std::ptrdiff_t>(*bestIdx));
}
return res;
}
TrackResults ClusterEngine::findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("ClustersEngine", "Find similar tracks from tracklist");
if (maxCount == 0)
return {};
Session& dbSession{ _db.getTLSSession() };
auto transaction{ dbSession.createReadTransaction() };
auto similarTrackIds{ Track::findSimilarTrackIds(dbSession, trackIds, Range{ 0, maxCount }) };
return std::move(similarTrackIds.results);
}
TrackContainer ClusterEngine::findSimilarTracksFromTrackList(TrackListId tracklistId, std::size_t maxCount) const
{
TrackContainer res;
if (maxCount == 0)
return res;
std::vector<db::TrackId> trackIds;
{
Session& dbSession{ _db.getTLSSession() };
db::Session& dbSession{ _db.getTLSSession() };
auto transaction{ dbSession.createReadTransaction() };
const TrackList::pointer trackList{ TrackList::find(dbSession, tracklistId) };
const db::TrackList::pointer trackList{ db::TrackList::find(dbSession, tracklistId) };
if (!trackList)
return res;
return {};
const auto tracks{ trackList->getSimilarTracks(0, maxCount) };
res.reserve(tracks.size());
std::transform(std::cbegin(tracks), std::cend(tracks), std::back_inserter(res), [](const auto& track) { return track->getId(); });
trackIds = trackList->getTrackIds();
}
return res;
if (trackIds.empty())
return {};
return findSimilarTracks(trackIds, maxCount);
}
ReleaseContainer ClusterEngine::getSimilarReleases(ReleaseId releaseId, std::size_t maxCount) const
ReleaseResults ClusterEngine::findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const
{
ReleaseContainer res;
if (maxCount == 0)
return res;
LMS_SCOPED_TRACE_DETAILED("ClustersEngine", "Find similar releases");
{
Session& dbSession{ _db.getTLSSession() };
auto transaction{ dbSession.createReadTransaction() };
auto release{ Release::find(dbSession, releaseId) };
if (!release)
return res;
const auto releases{ release->getSimilarReleases(0, maxCount) };
res.reserve(releases.size());
std::transform(std::cbegin(releases), std::cend(releases), std::back_inserter(res), [](const auto& release) { return release->getId(); });
}
return res;
}
ArtistContainer ClusterEngine::getSimilarArtists(ArtistId artistId, core::EnumSet<TrackArtistLinkType> artistLinkTypes, std::size_t maxCount) const
{
if (maxCount == 0)
return {};
Session& dbSession{ _db.getTLSSession() };
const auto queryIt{ _releaseClusters.find(releaseId) };
if (queryIt == _releaseClusters.cend() || queryIt->second.empty())
return {};
return findSimilarByClusterOverlap<db::ReleaseId>(_releaseClusters, releaseId, queryIt->second, maxCount);
}
ArtistResults ClusterEngine::findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("ClustersEngine", "Find similar artists");
if (maxCount == 0 || !linkTypes.contains(db::TrackArtistLinkType::Artist))
return {};
const auto queryIt{ _artistClusters.find(artistId) };
if (queryIt == _artistClusters.cend() || queryIt->second.empty())
return {};
return findSimilarByClusterOverlap<db::ArtistId>(_artistClusters, artistId, queryIt->second, maxCount);
}
TrackResults ClusterEngine::findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const
{
LMS_SCOPED_TRACE_DETAILED("ClustersEngine", "Find track similarity path");
if (maxCount == 0)
return {};
if (startTrackId == endTrackId)
return { RecommendationResult<db::TrackId>{ .id = startTrackId, .distance = {} } };
db::Session& dbSession{ _db.getTLSSession() };
auto transaction{ dbSession.createReadTransaction() };
auto artist{ Artist::find(dbSession, artistId) };
if (!artist)
const auto startTrack{ db::Track::find(dbSession, startTrackId) };
const auto endTrack{ db::Track::find(dbSession, endTrackId) };
if (!startTrack || !endTrack)
return {};
auto similarArtistIds{ artist->findSimilarArtistIds(artistLinkTypes, Range{ 0, maxCount }) };
return std::move(similarArtistIds.results);
TrackResults res;
res.reserve(std::min<std::size_t>(maxCount, 2));
res.push_back({ .id = startTrackId, .distance = {} });
if (maxCount > 1)
res.push_back({ .id = endTrackId, .distance = {} });
return res;
}
} // namespace lms::recommendation
#undef LOG
@@ -19,33 +19,52 @@
#pragma once
#include <span>
#include <unordered_map>
#include <vector>
#include "database/objects/ClusterId.hpp"
#include "track-selection-constraints/TrackCandidateEvaluator.hpp"
#include "track-selection-constraints/TrackMetadata.hpp"
#include "IEngine.hpp"
namespace lms::db
{
class Session;
}
namespace lms::recommendation
{
class ClusterEngine : public IEngine
{
public:
ClusterEngine(db::IDb& db)
: _db{ db } {}
~ClusterEngine() override = default;
ClusterEngine(db::IDb& db);
~ClusterEngine() override;
ClusterEngine(const ClusterEngine&) = delete;
ClusterEngine(ClusterEngine&&) = delete;
ClusterEngine& operator=(const ClusterEngine&) = delete;
ClusterEngine& operator=(ClusterEngine&&) = delete;
private:
void load(bool /*forceReload*/, const ProgressCallback& /*progressCallback*/) override {}
void requestCancelLoad() override {}
void load() override;
TrackContainer findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const override;
TrackContainer findSimilarTracks(const std::vector<db::TrackId>& trackIds, std::size_t maxCount) const override;
ReleaseContainer getSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const override;
ArtistContainer getSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const override;
TrackResults findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const override;
TrackResults findSimilarTracks(std::span<const db::TrackId> trackIds, std::size_t maxCount) const override;
TrackResults findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const override;
ReleaseResults findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const override;
ArtistResults findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const override;
TrackResults greedySelect(std::vector<db::TrackId> candidates, std::vector<db::TrackId> selectedTracks, std::size_t maxCount) const;
void buildTrackMetadata(db::Session& session);
void buildTrackClusters(db::Session& session);
void buildReleaseClusters();
void buildArtistClusters();
db::IDb& _db;
};
TrackMetadataMap _trackMetadata;
std::unordered_map<db::TrackId, std::vector<db::ClusterId>> _trackClusters;
std::unordered_map<db::ReleaseId, std::vector<db::ClusterId>> _releaseClusters;
std::unordered_map<db::ArtistId, std::vector<db::ClusterId>> _artistClusters;
TrackCandidateEvaluator _trackEvaluator;
};
} // namespace lms::recommendation
@@ -1,390 +0,0 @@
/*
* Copyright (C) 2019 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 "FeaturesDefs.hpp"
#include <algorithm>
#include <iterator>
#include "core/Exception.hpp"
namespace lms::recommendation
{
static const std::unordered_map<FeatureName, FeatureDef> featureDefinitions{
{ "lowlevel.average_loudness", { 1 } },
{ "lowlevel.barkbands.dmean", { 27 } },
{ "lowlevel.barkbands.dmean2", { 27 } },
{ "lowlevel.barkbands.dvar", { 27 } },
{ "lowlevel.barkbands.dvar2", { 27 } },
{ "lowlevel.barkbands.max", { 27 } },
{ "lowlevel.barkbands.mean", { 27 } },
{ "lowlevel.barkbands.median", { 27 } },
{ "lowlevel.barkbands.min", { 27 } },
{ "lowlevel.barkbands.var", { 27 } },
{ "lowlevel.barkbands_crest.dmean", { 1 } },
{ "lowlevel.barkbands_crest.dmean2", { 1 } },
{ "lowlevel.barkbands_crest.dvar", { 1 } },
{ "lowlevel.barkbands_crest.dvar2", { 1 } },
{ "lowlevel.barkbands_crest.max", { 1 } },
{ "lowlevel.barkbands_crest.mean", { 1 } },
{ "lowlevel.barkbands_crest.median", { 1 } },
{ "lowlevel.barkbands_crest.min", { 1 } },
{ "lowlevel.barkbands_crest.var", { 1 } },
{ "lowlevel.barkbands_flatness_db.dmean", { 1 } },
{ "lowlevel.barkbands_flatness_db.dmean2", { 1 } },
{ "lowlevel.barkbands_flatness_db.dvar", { 1 } },
{ "lowlevel.barkbands_flatness_db.dvar2", { 1 } },
{ "lowlevel.barkbands_flatness_db.max", { 1 } },
{ "lowlevel.barkbands_flatness_db.mean", { 1 } },
{ "lowlevel.barkbands_flatness_db.median", { 1 } },
{ "lowlevel.barkbands_flatness_db.min", { 1 } },
{ "lowlevel.barkbands_flatness_db.var", { 1 } },
{ "lowlevel.barkbands_kurtosis.dmean", { 1 } },
{ "lowlevel.barkbands_kurtosis.dmean2", { 1 } },
{ "lowlevel.barkbands_kurtosis.dvar", { 1 } },
{ "lowlevel.barkbands_kurtosis.dvar2", { 1 } },
{ "lowlevel.barkbands_kurtosis.max", { 1 } },
{ "lowlevel.barkbands_kurtosis.mean", { 1 } },
{ "lowlevel.barkbands_kurtosis.median", { 1 } },
{ "lowlevel.barkbands_kurtosis.min", { 1 } },
{ "lowlevel.barkbands_kurtosis.var", { 1 } },
{ "lowlevel.barkbands_skewness.dmean", { 1 } },
{ "lowlevel.barkbands_skewness.dmean2", { 1 } },
{ "lowlevel.barkbands_skewness.dvar", { 1 } },
{ "lowlevel.barkbands_skewness.dvar2", { 1 } },
{ "lowlevel.barkbands_skewness.max", { 1 } },
{ "lowlevel.barkbands_skewness.mean", { 1 } },
{ "lowlevel.barkbands_skewness.median", { 1 } },
{ "lowlevel.barkbands_skewness.min", { 1 } },
{ "lowlevel.barkbands_skewness.var", { 1 } },
{ "lowlevel.barkbands_spread.dmean", { 1 } },
{ "lowlevel.barkbands_spread.dmean2", { 1 } },
{ "lowlevel.barkbands_spread.dvar", { 1 } },
{ "lowlevel.barkbands_spread.dvar2", { 1 } },
{ "lowlevel.barkbands_spread.max", { 1 } },
{ "lowlevel.barkbands_spread.mean", { 1 } },
{ "lowlevel.barkbands_spread.median", { 1 } },
{ "lowlevel.barkbands_spread.min", { 1 } },
{ "lowlevel.barkbands_spread.var", { 1 } },
{ "lowlevel.dissonance.dmean", { 1 } },
{ "lowlevel.dissonance.dmean2", { 1 } },
{ "lowlevel.dissonance.dvar", { 1 } },
{ "lowlevel.dissonance.dvar2", { 1 } },
{ "lowlevel.dissonance.max", { 1 } },
{ "lowlevel.dissonance.mean", { 1 } },
{ "lowlevel.dissonance.median", { 1 } },
{ "lowlevel.dissonance.min", { 1 } },
{ "lowlevel.dissonance.var", { 1 } },
{ "lowlevel.dynamic_complexity", { 1 } },
{ "lowlevel.erbbands.dmean", { 40 } },
{ "lowlevel.erbbands.dmean2", { 40 } },
{ "lowlevel.erbbands.dvar", { 40 } },
{ "lowlevel.erbbands.dvar2", { 40 } },
{ "lowlevel.erbbands.max", { 40 } },
{ "lowlevel.erbbands.mean", { 40 } },
{ "lowlevel.erbbands.median", { 40 } },
{ "lowlevel.erbbands.min", { 40 } },
{ "lowlevel.erbbands.var", { 40 } },
{ "lowlevel.gfcc.mean", { 13 } },
{ "lowlevel.hfc.dmean", { 1 } },
{ "lowlevel.hfc.dmean2", { 1 } },
{ "lowlevel.hfc.dvar", { 1 } },
{ "lowlevel.hfc.dvar2", { 1 } },
{ "lowlevel.hfc.max", { 1 } },
{ "lowlevel.hfc.mean", { 1 } },
{ "lowlevel.hfc.median", { 1 } },
{ "lowlevel.hfc.min", { 1 } },
{ "lowlevel.hfc.var", { 1 } },
{ "tonal.hpcp.median", { 36 } },
{ "lowlevel.melbands.dmean", { 40 } },
{ "lowlevel.melbands.dmean2", { 40 } },
{ "lowlevel.melbands.dvar", { 40 } },
{ "lowlevel.melbands.dvar2", { 40 } },
{ "lowlevel.melbands.max", { 40 } },
{ "lowlevel.melbands.mean", { 40 } },
{ "lowlevel.melbands.median", { 40 } },
{ "lowlevel.melbands.min", { 40 } },
{ "lowlevel.melbands.var", { 40 } },
{ "lowlevel.melbands_crest.dmean", { 1 } },
{ "lowlevel.melbands_crest.dmean2", { 1 } },
{ "lowlevel.melbands_crest.dvar", { 1 } },
{ "lowlevel.melbands_crest.dvar2", { 1 } },
{ "lowlevel.melbands_crest.max", { 1 } },
{ "lowlevel.melbands_crest.mean", { 1 } },
{ "lowlevel.melbands_crest.median", { 1 } },
{ "lowlevel.melbands_crest.min", { 1 } },
{ "lowlevel.melbands_crest.var", { 1 } },
{ "lowlevel.melbands_flatness_db.dmean", { 1 } },
{ "lowlevel.melbands_flatness_db.dmean2", { 1 } },
{ "lowlevel.melbands_flatness_db.dvar", { 1 } },
{ "lowlevel.melbands_flatness_db.dvar2", { 1 } },
{ "lowlevel.melbands_flatness_db.max", { 1 } },
{ "lowlevel.melbands_flatness_db.mean", { 1 } },
{ "lowlevel.melbands_flatness_db.median", { 1 } },
{ "lowlevel.melbands_flatness_db.min", { 1 } },
{ "lowlevel.melbands_flatness_db.var", { 1 } },
{ "lowlevel.melbands_kurtosis.dmean", { 1 } },
{ "lowlevel.melbands_kurtosis.dmean2", { 1 } },
{ "lowlevel.melbands_kurtosis.dvar", { 1 } },
{ "lowlevel.melbands_kurtosis.dvar2", { 1 } },
{ "lowlevel.melbands_kurtosis.max", { 1 } },
{ "lowlevel.melbands_kurtosis.mean", { 1 } },
{ "lowlevel.melbands_kurtosis.median", { 1 } },
{ "lowlevel.melbands_kurtosis.min", { 1 } },
{ "lowlevel.melbands_kurtosis.var", { 1 } },
{ "lowlevel.melbands_skewness.dmean", { 1 } },
{ "lowlevel.melbands_skewness.dmean2", { 1 } },
{ "lowlevel.melbands_skewness.dvar", { 1 } },
{ "lowlevel.melbands_skewness.dvar2", { 1 } },
{ "lowlevel.melbands_skewness.max", { 1 } },
{ "lowlevel.melbands_skewness.mean", { 1 } },
{ "lowlevel.melbands_skewness.median", { 1 } },
{ "lowlevel.melbands_skewness.min", { 1 } },
{ "lowlevel.melbands_skewness.var", { 1 } },
{ "lowlevel.melbands_spread.dmean", { 1 } },
{ "lowlevel.melbands_spread.dmean2", { 1 } },
{ "lowlevel.melbands_spread.dvar", { 1 } },
{ "lowlevel.melbands_spread.dvar2", { 1 } },
{ "lowlevel.melbands_spread.max", { 1 } },
{ "lowlevel.melbands_spread.mean", { 1 } },
{ "lowlevel.melbands_spread.median", { 1 } },
{ "lowlevel.melbands_spread.min", { 1 } },
{ "lowlevel.melbands_spread.var", { 1 } },
{ "lowlevel.mfcc.mean", { 13 } },
{ "lowlevel.pitch_salience.dmean", { 1 } },
{ "lowlevel.pitch_salience.dmean2", { 1 } },
{ "lowlevel.pitch_salience.dvar", { 1 } },
{ "lowlevel.pitch_salience.dvar2", { 1 } },
{ "lowlevel.pitch_salience.max", { 1 } },
{ "lowlevel.pitch_salience.mean", { 1 } },
{ "lowlevel.pitch_salience.median", { 1 } },
{ "lowlevel.pitch_salience.min", { 1 } },
{ "lowlevel.pitch_salience.var", { 1 } },
{ "lowlevel.silence_rate_30dB.dmean", { 1 } },
{ "lowlevel.silence_rate_30dB.dmean2", { 1 } },
{ "lowlevel.silence_rate_30dB.dvar", { 1 } },
{ "lowlevel.silence_rate_30dB.dvar2", { 1 } },
{ "lowlevel.silence_rate_30dB.max", { 1 } },
{ "lowlevel.silence_rate_30dB.mean", { 1 } },
{ "lowlevel.silence_rate_30dB.median", { 1 } },
{ "lowlevel.silence_rate_30dB.min", { 1 } },
{ "lowlevel.silence_rate_30dB.var", { 1 } },
{ "lowlevel.silence_rate_60dB.dmean", { 1 } },
{ "lowlevel.silence_rate_60dB.dmean2", { 1 } },
{ "lowlevel.silence_rate_60dB.dvar", { 1 } },
{ "lowlevel.silence_rate_60dB.dvar2", { 1 } },
{ "lowlevel.silence_rate_60dB.max", { 1 } },
{ "lowlevel.silence_rate_60dB.mean", { 1 } },
{ "lowlevel.silence_rate_60dB.median", { 1 } },
{ "lowlevel.silence_rate_60dB.min", { 1 } },
{ "lowlevel.silence_rate_60dB.var", { 1 } },
{ "lowlevel.spectral_centroid.dmean", { 1 } },
{ "lowlevel.spectral_centroid.dmean2", { 1 } },
{ "lowlevel.spectral_centroid.dvar", { 1 } },
{ "lowlevel.spectral_centroid.dvar2", { 1 } },
{ "lowlevel.spectral_centroid.max", { 1 } },
{ "lowlevel.spectral_centroid.mean", { 1 } },
{ "lowlevel.spectral_centroid.median", { 1 } },
{ "lowlevel.spectral_centroid.min", { 1 } },
{ "lowlevel.spectral_centroid.var", { 1 } },
{ "lowlevel.spectral_complexity.dmean", { 1 } },
{ "lowlevel.spectral_complexity.dmean2", { 1 } },
{ "lowlevel.spectral_complexity.dvar", { 1 } },
{ "lowlevel.spectral_complexity.dvar2", { 1 } },
{ "lowlevel.spectral_complexity.max", { 1 } },
{ "lowlevel.spectral_complexity.mean", { 1 } },
{ "lowlevel.spectral_complexity.median", { 1 } },
{ "lowlevel.spectral_complexity.min", { 1 } },
{ "lowlevel.spectral_complexity.var", { 1 } },
{ "lowlevel.spectral_contrast_coeffs.dmean", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.dmean2", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.dvar", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.dvar2", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.max", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.mean", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.median", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.min", { 6 } },
{ "lowlevel.spectral_contrast_coeffs.var", { 6 } },
{ "lowlevel.spectral_contrast_valleys.dmean", { 6 } },
{ "lowlevel.spectral_contrast_valleys.dmean2", { 6 } },
{ "lowlevel.spectral_contrast_valleys.dvar", { 6 } },
{ "lowlevel.spectral_contrast_valleys.dvar2", { 6 } },
{ "lowlevel.spectral_contrast_valleys.max", { 6 } },
{ "lowlevel.spectral_contrast_valleys.mean", { 6 } },
{ "lowlevel.spectral_contrast_valleys.median", { 6 } },
{ "lowlevel.spectral_contrast_valleys.min", { 6 } },
{ "lowlevel.spectral_contrast_valleys.var", { 6 } },
{ "lowlevel.spectral_decrease.dmean", { 1 } },
{ "lowlevel.spectral_decrease.dmean2", { 1 } },
{ "lowlevel.spectral_decrease.dvar", { 1 } },
{ "lowlevel.spectral_decrease.dvar2", { 1 } },
{ "lowlevel.spectral_decrease.max", { 1 } },
{ "lowlevel.spectral_decrease.mean", { 1 } },
{ "lowlevel.spectral_decrease.median", { 1 } },
{ "lowlevel.spectral_decrease.min", { 1 } },
{ "lowlevel.spectral_decrease.var", { 1 } },
{ "lowlevel.spectral_energy.dmean", { 1 } },
{ "lowlevel.spectral_energy.dmean2", { 1 } },
{ "lowlevel.spectral_energy.dvar", { 1 } },
{ "lowlevel.spectral_energy.dvar2", { 1 } },
{ "lowlevel.spectral_energy.max", { 1 } },
{ "lowlevel.spectral_energy.mean", { 1 } },
{ "lowlevel.spectral_energy.median", { 1 } },
{ "lowlevel.spectral_energy.min", { 1 } },
{ "lowlevel.spectral_energy.var", { 1 } },
{ "lowlevel.spectral_energyband_high.dmean", { 1 } },
{ "lowlevel.spectral_energyband_high.dmean2", { 1 } },
{ "lowlevel.spectral_energyband_high.dvar", { 1 } },
{ "lowlevel.spectral_energyband_high.dvar2", { 1 } },
{ "lowlevel.spectral_energyband_high.max", { 1 } },
{ "lowlevel.spectral_energyband_high.mean", { 1 } },
{ "lowlevel.spectral_energyband_high.median", { 1 } },
{ "lowlevel.spectral_energyband_high.min", { 1 } },
{ "lowlevel.spectral_energyband_high.var", { 1 } },
{ "lowlevel.spectral_energyband_low.dmean", { 1 } },
{ "lowlevel.spectral_energyband_low.dmean2", { 1 } },
{ "lowlevel.spectral_energyband_low.dvar", { 1 } },
{ "lowlevel.spectral_energyband_low.dvar2", { 1 } },
{ "lowlevel.spectral_energyband_low.max", { 1 } },
{ "lowlevel.spectral_energyband_low.mean", { 1 } },
{ "lowlevel.spectral_energyband_low.median", { 1 } },
{ "lowlevel.spectral_energyband_low.min", { 1 } },
{ "lowlevel.spectral_energyband_low.var", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.dmean", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.dmean2", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.dvar", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.dvar2", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.max", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.mean", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.median", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.min", { 1 } },
{ "lowlevel.spectral_energyband_middle_high.var", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.dmean", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.dmean2", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.dvar", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.dvar2", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.max", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.mean", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.median", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.min", { 1 } },
{ "lowlevel.spectral_energyband_middle_low.var", { 1 } },
{ "lowlevel.spectral_entropy.dmean", { 1 } },
{ "lowlevel.spectral_entropy.dmean2", { 1 } },
{ "lowlevel.spectral_entropy.dvar", { 1 } },
{ "lowlevel.spectral_entropy.dvar2", { 1 } },
{ "lowlevel.spectral_entropy.max", { 1 } },
{ "lowlevel.spectral_entropy.mean", { 1 } },
{ "lowlevel.spectral_entropy.median", { 1 } },
{ "lowlevel.spectral_entropy.min", { 1 } },
{ "lowlevel.spectral_entropy.var", { 1 } },
{ "lowlevel.spectral_flux.dmean", { 1 } },
{ "lowlevel.spectral_flux.dmean2", { 1 } },
{ "lowlevel.spectral_flux.dvar", { 1 } },
{ "lowlevel.spectral_flux.dvar2", { 1 } },
{ "lowlevel.spectral_flux.max", { 1 } },
{ "lowlevel.spectral_flux.mean", { 1 } },
{ "lowlevel.spectral_flux.median", { 1 } },
{ "lowlevel.spectral_flux.min", { 1 } },
{ "lowlevel.spectral_flux.var", { 1 } },
{ "lowlevel.spectral_kurtosis.dmean", { 1 } },
{ "lowlevel.spectral_kurtosis.dmean2", { 1 } },
{ "lowlevel.spectral_kurtosis.dvar", { 1 } },
{ "lowlevel.spectral_kurtosis.dvar2", { 1 } },
{ "lowlevel.spectral_kurtosis.max", { 1 } },
{ "lowlevel.spectral_kurtosis.mean", { 1 } },
{ "lowlevel.spectral_kurtosis.median", { 1 } },
{ "lowlevel.spectral_kurtosis.min", { 1 } },
{ "lowlevel.spectral_kurtosis.var", { 1 } },
{ "lowlevel.spectral_rms.dmean", { 1 } },
{ "lowlevel.spectral_rms.dmean2", { 1 } },
{ "lowlevel.spectral_rms.dvar", { 1 } },
{ "lowlevel.spectral_rms.dvar2", { 1 } },
{ "lowlevel.spectral_rms.max", { 1 } },
{ "lowlevel.spectral_rms.mean", { 1 } },
{ "lowlevel.spectral_rms.median", { 1 } },
{ "lowlevel.spectral_rms.min", { 1 } },
{ "lowlevel.spectral_rms.var", { 1 } },
{ "lowlevel.spectral_rolloff.dmean", { 1 } },
{ "lowlevel.spectral_rolloff.dmean2", { 1 } },
{ "lowlevel.spectral_rolloff.dvar", { 1 } },
{ "lowlevel.spectral_rolloff.dvar2", { 1 } },
{ "lowlevel.spectral_rolloff.max", { 1 } },
{ "lowlevel.spectral_rolloff.mean", { 1 } },
{ "lowlevel.spectral_rolloff.median", { 1 } },
{ "lowlevel.spectral_rolloff.min", { 1 } },
{ "lowlevel.spectral_rolloff.var", { 1 } },
{ "lowlevel.spectral_skewness.dmean", { 1 } },
{ "lowlevel.spectral_skewness.dmean2", { 1 } },
{ "lowlevel.spectral_skewness.dvar", { 1 } },
{ "lowlevel.spectral_skewness.dvar2", { 1 } },
{ "lowlevel.spectral_skewness.max", { 1 } },
{ "lowlevel.spectral_skewness.mean", { 1 } },
{ "lowlevel.spectral_skewness.median", { 1 } },
{ "lowlevel.spectral_skewness.min", { 1 } },
{ "lowlevel.spectral_skewness.var", { 1 } },
{ "lowlevel.spectral_spread.dmean", { 1 } },
{ "lowlevel.spectral_spread.dmean2", { 1 } },
{ "lowlevel.spectral_spread.dvar", { 1 } },
{ "lowlevel.spectral_spread.dvar2", { 1 } },
{ "lowlevel.spectral_spread.max", { 1 } },
{ "lowlevel.spectral_spread.mean", { 1 } },
{ "lowlevel.spectral_spread.median", { 1 } },
{ "lowlevel.spectral_spread.min", { 1 } },
{ "lowlevel.spectral_spread.var", { 1 } },
{ "lowlevel.spectral_strongpeak.dmean", { 1 } },
{ "lowlevel.spectral_strongpeak.dmean2", { 1 } },
{ "lowlevel.spectral_strongpeak.dvar", { 1 } },
{ "lowlevel.spectral_strongpeak.dvar2", { 1 } },
{ "lowlevel.spectral_strongpeak.max", { 1 } },
{ "lowlevel.spectral_strongpeak.mean", { 1 } },
{ "lowlevel.spectral_strongpeak.median", { 1 } },
{ "lowlevel.spectral_strongpeak.min", { 1 } },
{ "lowlevel.spectral_strongpeak.var", { 1 } },
{ "lowlevel.zerocrossingrate.dmean", { 1 } },
{ "lowlevel.zerocrossingrate.dmean2", { 1 } },
{ "lowlevel.zerocrossingrate.dvar", { 1 } },
{ "lowlevel.zerocrossingrate.dvar2", { 1 } },
{ "lowlevel.zerocrossingrate.max", { 1 } },
{ "lowlevel.zerocrossingrate.mean", { 1 } },
{ "lowlevel.zerocrossingrate.median", { 1 } },
{ "lowlevel.zerocrossingrate.min", { 1 } },
{ "lowlevel.zerocrossingrate.var", { 1 } },
};
FeatureDef getFeatureDef(const FeatureName& featureName)
{
auto it{ featureDefinitions.find(featureName) };
if (it == std::cend(featureDefinitions))
throw core::LmsException{ "Unhandled requested feature '" + featureName + "'" };
return it->second;
}
FeatureNames getFeatureNames()
{
FeatureNames res;
std::transform(std::cbegin(featureDefinitions), std::cend(featureDefinitions),
std::inserter(res, std::begin(res)), [](auto itFeature) { return itFeature.first; });
return res;
}
} // namespace lms::recommendation
@@ -1,409 +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 "FeaturesEngine.hpp"
#include <numeric>
#include "core/ILogger.hpp"
#include "core/Random.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Artist.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/TrackArtistLink.hpp"
#include "database/objects/TrackFeatures.hpp"
#include "database/objects/TrackList.hpp"
#include "som/DataNormalizer.hpp"
namespace lms::recommendation
{
using namespace db;
std::unique_ptr<IEngine> createFeaturesEngine(db::IDb& db)
{
return std::make_unique<FeaturesEngine>(db);
}
namespace
{
std::optional<som::InputVector> convertFeatureValuesMapToInputVector(const FeatureValuesMap& featureValuesMap, std::size_t nbDimensions)
{
std::size_t i{};
std::optional<som::InputVector> res{ som::InputVector{ nbDimensions } };
for (const auto& [featureName, values] : featureValuesMap)
{
if (values.size() != getFeatureDef(featureName).nbDimensions)
{
LMS_LOG(RECOMMENDATION, WARNING, "Dimension mismatch for feature '" << featureName << "'. Expected " << getFeatureDef(featureName).nbDimensions << ", got " << values.size());
res.reset();
break;
}
for (double val : values)
(*res)[i++] = val;
}
return res;
}
som::InputVector getInputVectorWeights(const FeatureSettingsMap& featureSettingsMap, std::size_t nbDimensions)
{
som::InputVector weights{ nbDimensions };
std::size_t index{};
for (const auto& [featureName, featureSettings] : featureSettingsMap)
{
const std::size_t featureNbDimensions{ getFeatureDef(featureName).nbDimensions };
for (std::size_t i{}; i < featureNbDimensions; ++i)
weights[index++] = (1. / featureNbDimensions * featureSettings.weight);
}
assert(index == nbDimensions);
return weights;
}
} // namespace
const FeatureSettingsMap& FeaturesEngine::getDefaultTrainFeatureSettings()
{
static const FeatureSettingsMap defaultTrainFeatureSettings{
{ "lowlevel.spectral_energyband_high.mean", { 1 } },
{ "lowlevel.spectral_rolloff.median", { 1 } },
{ "lowlevel.spectral_contrast_valleys.var", { 1 } },
{ "lowlevel.erbbands.mean", { 1 } },
{ "lowlevel.gfcc.mean", { 1 } },
};
return defaultTrainFeatureSettings;
}
void FeaturesEngine::loadFromTraining(const TrainSettings& trainSettings, const ProgressCallback& progressCallback)
{
LMS_LOG(RECOMMENDATION, INFO, "Constructing features classifier...");
std::unordered_set<FeatureName> featureNames;
std::transform(std::cbegin(trainSettings.featureSettingsMap), std::cend(trainSettings.featureSettingsMap), std::inserter(featureNames, std::begin(featureNames)),
[](const auto& itFeatureSetting) { return itFeatureSetting.first; });
const std::size_t nbDimensions{ std::accumulate(std::cbegin(featureNames), std::cend(featureNames), std::size_t{ 0 },
[](std::size_t sum, const FeatureName& featureName) { return sum + getFeatureDef(featureName).nbDimensions; }) };
LMS_LOG(RECOMMENDATION, DEBUG, "Features dimension = " << nbDimensions);
Session& session{ _db.getTLSSession() };
RangeResults<TrackFeaturesId> trackFeaturesIds;
{
auto transaction{ session.createReadTransaction() };
LMS_LOG(RECOMMENDATION, DEBUG, "Getting Track features...");
trackFeaturesIds = TrackFeatures::find(session);
LMS_LOG(RECOMMENDATION, DEBUG, "Getting Track features DONE (found " << trackFeaturesIds.results.size() << " track features)");
}
std::vector<som::InputVector> samples;
std::vector<TrackId> samplesTrackIds;
samples.reserve(trackFeaturesIds.results.size());
samplesTrackIds.reserve(trackFeaturesIds.results.size());
LMS_LOG(RECOMMENDATION, DEBUG, "Extracting features...");
// TODO handle errors using exceptions
for (const TrackFeaturesId trackFeaturesId : trackFeaturesIds.results)
{
if (_loadCancelled)
return;
auto transaction{ session.createReadTransaction() };
TrackFeatures::pointer trackFeatures{ TrackFeatures::find(session, trackFeaturesId) };
if (!trackFeatures)
continue;
FeatureValuesMap featureValuesMap{ trackFeatures->getFeatureValuesMap(featureNames) };
if (featureValuesMap.empty())
continue;
std::optional<som::InputVector> inputVector{ convertFeatureValuesMapToInputVector(featureValuesMap, nbDimensions) };
if (!inputVector)
continue;
samples.emplace_back(std::move(*inputVector));
samplesTrackIds.emplace_back(trackFeatures->getTrack()->getId());
}
LMS_LOG(RECOMMENDATION, DEBUG, "Extracting features DONE");
if (samples.empty())
{
LMS_LOG(RECOMMENDATION, INFO, "Nothing to classify!");
return;
}
LMS_LOG(RECOMMENDATION, DEBUG, "Normalizing data...");
som::DataNormalizer dataNormalizer{ nbDimensions };
dataNormalizer.computeNormalizationFactors(samples);
for (auto& sample : samples)
dataNormalizer.normalizeData(sample);
som::Coordinate size{ static_cast<som::Coordinate>(std::sqrt(samples.size() / trainSettings.sampleCountPerNeuron)) };
if (size < 2)
{
LMS_LOG(RECOMMENDATION, WARNING, "Very few tracks (" << samples.size() << ") are being used by the features engine, expect bad behaviors");
size = 2;
}
LMS_LOG(RECOMMENDATION, INFO, "Found " << samples.size() << " tracks, constructing a " << size << "*" << size << " network");
som::Network network{ size, size, nbDimensions };
som::InputVector weights{ getInputVectorWeights(trainSettings.featureSettingsMap, nbDimensions) };
network.setDataWeights(weights);
auto somProgressCallback{ [&](const som::Network::CurrentIteration& iter) {
LMS_LOG(RECOMMENDATION, DEBUG, "Current pass = " << iter.idIteration << " / " << iter.iterationCount);
progressCallback(Progress{ iter.idIteration, iter.iterationCount });
} };
LMS_LOG(RECOMMENDATION, DEBUG, "Training network...");
network.train(samples, trainSettings.iterationCount,
progressCallback ? somProgressCallback : som::Network::ProgressCallback{},
[this] { return _loadCancelled; });
LMS_LOG(RECOMMENDATION, DEBUG, "Training network DONE");
LMS_LOG(RECOMMENDATION, DEBUG, "Classifying tracks...");
TrackPositions trackPositions;
for (std::size_t i{}; i < samples.size(); ++i)
{
if (_loadCancelled)
return;
const som::Position position{ network.getClosestRefVectorPosition(samples[i]) };
trackPositions[samplesTrackIds[i]].push_back(position);
}
LMS_LOG(RECOMMENDATION, DEBUG, "Classifying tracks DONE");
load(std::move(network), std::move(trackPositions));
}
void FeaturesEngine::loadFromCache(FeaturesEngineCache&& cache)
{
LMS_LOG(RECOMMENDATION, INFO, "Constructing features classifier from cache...");
load(std::move(cache._network), cache._trackPositions);
}
TrackContainer FeaturesEngine::findSimilarTracksFromTrackList(TrackListId trackListId, std::size_t maxCount) const
{
const TrackContainer trackIds{ [&] {
TrackContainer res;
Session& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
const TrackList::pointer trackList{ TrackList::find(session, trackListId) };
if (trackList)
res = trackList->getTrackIds();
return res;
}() };
return findSimilarTracks(trackIds, maxCount);
}
TrackContainer FeaturesEngine::findSimilarTracks(const std::vector<TrackId>& tracksIds, std::size_t maxCount) const
{
auto similarTrackIds{ getSimilarObjects(tracksIds, _trackMatrix, _trackPositions, maxCount) };
Session& session{ _db.getTLSSession() };
{
// Report only existing ids, as tracks may have been removed a long time ago (refreshing the SOM takes some time)
auto transaction{ session.createReadTransaction() };
similarTrackIds.erase(std::remove_if(std::begin(similarTrackIds), std::end(similarTrackIds),
[&](TrackId trackId) {
return !Track::exists(session, trackId);
}),
std::end(similarTrackIds));
}
return similarTrackIds;
}
ReleaseContainer FeaturesEngine::getSimilarReleases(ReleaseId releaseId, std::size_t maxCount) const
{
auto similarReleaseIds{ getSimilarObjects({ releaseId }, _releaseMatrix, _releasePositions, maxCount) };
Session& session{ _db.getTLSSession() };
if (!similarReleaseIds.empty())
{
// Report only existing ids
auto transaction{ session.createReadTransaction() };
similarReleaseIds.erase(std::remove_if(std::begin(similarReleaseIds), std::end(similarReleaseIds),
[&](ReleaseId releaseId) {
return !Release::exists(session, releaseId);
}),
std::end(similarReleaseIds));
}
return similarReleaseIds;
}
ArtistContainer FeaturesEngine::getSimilarArtists(ArtistId artistId, core::EnumSet<TrackArtistLinkType> linkTypes, std::size_t maxCount) const
{
auto getSimilarArtistIdsForLinkType{ [&](TrackArtistLinkType linkType) {
ArtistContainer similarArtistIds;
const auto itArtists{ _artistMatrix.find(linkType) };
if (itArtists == std::cend(_artistMatrix))
{
return similarArtistIds;
}
return getSimilarObjects({ artistId }, itArtists->second, _artistPositions, maxCount);
} };
std::unordered_set<ArtistId> similarArtistIds;
for (TrackArtistLinkType linkType : linkTypes)
{
const auto similarArtistIdsForLinkType{ getSimilarArtistIdsForLinkType(linkType) };
similarArtistIds.insert(std::begin(similarArtistIdsForLinkType), std::end(similarArtistIdsForLinkType));
}
ArtistContainer res(std::cbegin(similarArtistIds), std::cend(similarArtistIds));
Session& session{ _db.getTLSSession() };
{
// Report only existing ids
auto transaction{ session.createReadTransaction() };
res.erase(std::remove_if(std::begin(res), std::end(res),
[&](ArtistId artistId) {
return !Artist::exists(session, artistId);
}),
std::end(res));
}
while (res.size() > maxCount)
res.erase(core::random::pickRandom(res));
return res;
}
FeaturesEngineCache FeaturesEngine::toCache() const
{
return FeaturesEngineCache{ *_network, _trackPositions };
}
void FeaturesEngine::load(bool forceReload, const ProgressCallback& progressCallback)
{
if (forceReload)
{
FeaturesEngineCache::invalidate();
}
else if (std::optional<FeaturesEngineCache> cache{ FeaturesEngineCache::read() })
{
loadFromCache(std::move(*cache));
return;
}
TrainSettings trainSettings;
trainSettings.featureSettingsMap = getDefaultTrainFeatureSettings();
loadFromTraining(trainSettings, progressCallback);
if (!_loadCancelled && _network)
toCache().write();
}
void FeaturesEngine::requestCancelLoad()
{
LMS_LOG(RECOMMENDATION, DEBUG, "Requesting init cancellation");
_loadCancelled = true;
}
void FeaturesEngine::load(const som::Network& network, const TrackPositions& trackPositions)
{
using namespace db;
_networkRefVectorsDistanceMedian = network.computeRefVectorsDistanceMedian();
LMS_LOG(RECOMMENDATION, DEBUG, "Median distance betweend ref vectors = " << _networkRefVectorsDistanceMedian);
const som::Coordinate width{ network.getWidth() };
const som::Coordinate height{ network.getHeight() };
_releaseMatrix = ReleaseMatrix{ width, height };
_trackMatrix = TrackMatrix{ width, height };
LMS_LOG(RECOMMENDATION, DEBUG, "Constructing maps...");
Session& session{ _db.getTLSSession() };
for (const auto& [trackId, positions] : trackPositions)
{
if (_loadCancelled)
return;
auto transaction{ session.createReadTransaction() };
const Track::pointer track{ Track::find(session, trackId) };
if (!track)
continue;
for (const som::Position& position : positions)
{
core::utils::push_back_if_not_present(_trackPositions[trackId], position);
core::utils::push_back_if_not_present(_trackMatrix[position], trackId);
if (Release::pointer release{ track->getRelease() })
{
const ReleaseId releaseId{ release->getId() };
core::utils::push_back_if_not_present(_releasePositions[releaseId], position);
core::utils::push_back_if_not_present(_releaseMatrix[position], releaseId);
}
for (const TrackArtistLink::pointer& artistLink : track->getArtistLinks())
{
const ArtistId artistId{ artistLink->getArtist()->getId() };
core::utils::push_back_if_not_present(_artistPositions[artistId], position);
auto itArtists{ _artistMatrix.find(artistLink->getType()) };
if (itArtists == std::cend(_artistMatrix))
{
[[maybe_unused]] auto [it, inserted] = _artistMatrix.try_emplace(artistLink->getType(), ArtistMatrix{ width, height });
assert(inserted);
itArtists = it;
}
core::utils::push_back_if_not_present(itArtists->second[position], artistId);
}
}
}
_network = std::make_unique<som::Network>(network);
LMS_LOG(RECOMMENDATION, INFO, "Classifier successfully loaded!");
}
} // namespace lms::recommendation
@@ -1,202 +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/>.
*/
#pragma once
#include <algorithm>
#include <functional>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include "core/Utils.hpp"
#include "som/DataNormalizer.hpp"
#include "som/Network.hpp"
#include "FeaturesDefs.hpp"
#include "FeaturesEngineCache.hpp"
#include "IEngine.hpp"
namespace lms::db
{
class Session;
}
namespace lms::recommendation
{
using FeatureWeight = double;
class FeaturesEngine : public IEngine
{
public:
FeaturesEngine(db::IDb& db)
: _db{ db } {}
FeaturesEngine(const FeaturesEngine&) = delete;
FeaturesEngine(FeaturesEngine&&) = delete;
FeaturesEngine& operator=(const FeaturesEngine&) = delete;
FeaturesEngine& operator=(FeaturesEngine&&) = delete;
static const FeatureSettingsMap& getDefaultTrainFeatureSettings();
private:
void load(bool forceReload, const ProgressCallback& progressCallback) override;
void requestCancelLoad() override;
TrackContainer findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const override;
TrackContainer findSimilarTracks(const std::vector<db::TrackId>& tracksId, std::size_t maxCount) const override;
ReleaseContainer getSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const override;
ArtistContainer getSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const override;
void loadFromCache(FeaturesEngineCache&& cache);
// Use training (may be very slow)
struct TrainSettings
{
std::size_t iterationCount{ 10 };
float sampleCountPerNeuron{ 4 };
FeatureSettingsMap featureSettingsMap;
};
void loadFromTraining(const TrainSettings& trainSettings, const ProgressCallback& progressCallback);
template<typename IdType>
using ObjectPositions = std::unordered_map<IdType, std::vector<som::Position>>;
using ArtistPositions = ObjectPositions<db::ArtistId>;
using ReleasePositions = ObjectPositions<db::ReleaseId>;
using TrackPositions = ObjectPositions<db::TrackId>;
template<typename IdType>
using ObjectMatrix = som::Matrix<std::vector<IdType>>;
using ArtistMatrix = ObjectMatrix<db::ArtistId>;
using ReleaseMatrix = ObjectMatrix<db::ReleaseId>;
using TrackMatrix = ObjectMatrix<db::TrackId>;
void load(const som::Network& network, const TrackPositions& tracksPosition);
FeaturesEngineCache toCache() const;
template<typename IdType>
static std::vector<som::Position> getMatchingRefVectorsPosition(const std::vector<IdType>& ids, const ObjectPositions<IdType>& objectPositions);
template<typename IdType>
static std::vector<IdType> getObjectsIds(const std::vector<som::Position>& positions, const ObjectMatrix<IdType>& objectsMatrix);
template<typename IdType>
std::vector<IdType> getSimilarObjects(const std::vector<IdType>& ids,
const ObjectMatrix<IdType>& objectMatrix,
const ObjectPositions<IdType>& objectPositions,
std::size_t maxCount) const;
db::IDb& _db;
bool _loadCancelled{};
std::unique_ptr<som::Network> _network;
double _networkRefVectorsDistanceMedian{};
ArtistPositions _artistPositions;
std::unordered_map<db::TrackArtistLinkType, ArtistMatrix> _artistMatrix;
ReleasePositions _releasePositions;
ReleaseMatrix _releaseMatrix;
TrackPositions _trackPositions;
TrackMatrix _trackMatrix;
};
template<typename IdType>
std::vector<som::Position> FeaturesEngine::getMatchingRefVectorsPosition(const std::vector<IdType>& ids, const ObjectPositions<IdType>& objectPositions)
{
std::vector<som::Position> res;
if (ids.empty())
return res;
for (const IdType id : ids)
{
auto it = objectPositions.find(id);
if (it == objectPositions.end())
continue;
for (const som::Position& position : it->second)
core::utils::push_back_if_not_present(res, position);
}
return res;
}
template<typename IdType>
std::vector<IdType> FeaturesEngine::getObjectsIds(const std::vector<som::Position>& positions, const ObjectMatrix<IdType>& objectMatrix)
{
std::vector<IdType> res;
for (const som::Position& position : positions)
{
for (const IdType id : objectMatrix.get(position))
core::utils::push_back_if_not_present(res, id);
}
return res;
}
template<typename IdType>
std::vector<IdType> FeaturesEngine::getSimilarObjects(const std::vector<IdType>& ids,
const ObjectMatrix<IdType>& objectMatrix,
const ObjectPositions<IdType>& objectPositions,
std::size_t maxCount) const
{
std::vector<IdType> res;
std::vector<som::Position> searchedRefVectorsPosition{ getMatchingRefVectorsPosition(ids, objectPositions) };
if (searchedRefVectorsPosition.empty())
return res;
while (1)
{
std::vector<IdType> closestObjectIds{ getObjectsIds(searchedRefVectorsPosition, objectMatrix) };
// Remove objects that are already in input or already reported
closestObjectIds.erase(std::remove_if(std::begin(closestObjectIds), std::end(closestObjectIds),
[&](IdType id) {
return std::find(std::cbegin(ids), std::cend(ids), id) != std::cend(ids);
}),
std::end(closestObjectIds));
for (IdType id : closestObjectIds)
{
if (res.size() == maxCount)
break;
core::utils::push_back_if_not_present(res, id);
}
if (res.size() == maxCount)
break;
// If there is not enough objects, try again with closest neighbour until there is too much distance
const std::optional<som::Position> closestRefVectorPosition{ _network->getClosestRefVectorPosition(searchedRefVectorsPosition, _networkRefVectorsDistanceMedian * 0.75) };
if (!closestRefVectorPosition)
break;
core::utils::push_back_if_not_present(searchedRefVectorsPosition, closestRefVectorPosition.value());
}
return res;
}
} // namespace lms::recommendation
@@ -1,248 +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 "FeaturesEngineCache.hpp"
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/xml_parser.hpp>
#include "core/IConfig.hpp"
#include "core/ILogger.hpp"
#include "core/Service.hpp"
namespace lms::recommendation
{
namespace
{
std::filesystem::path getCacheDirectory()
{
return core::Service<core::IConfig>::get()->getPath("working-dir", "/var/lms") / "cache" / "features";
}
std::filesystem::path getCacheNetworkFilePath()
{
return getCacheDirectory() / "network";
}
std::filesystem::path getCacheTrackPositionsFilePath()
{
return getCacheDirectory() / "track_positions";
}
bool networkToCacheFile(const som::Network& network, std::filesystem::path path)
{
try
{
boost::property_tree::ptree root;
root.put("width", network.getWidth());
root.put("height", network.getHeight());
root.put("dim_count", network.getInputDimCount());
for (som::InputVector::value_type weight : network.getDataWeights())
root.add("weights.weight", weight);
for (som::Coordinate x = 0; x < network.getWidth(); ++x)
{
for (som::Coordinate y = 0; y < network.getWidth(); ++y)
{
const auto& refVector = network.getRefVector({ x, y });
boost::property_tree::ptree node;
for (const auto& value : refVector)
node.add("values.value", value);
node.put("coord_x", x);
node.put("coord_y", y);
root.add_child("ref_vectors.ref_vector", node);
}
}
boost::property_tree::write_xml(path.string(), root);
LMS_LOG(RECOMMENDATION, DEBUG, "Created network cache");
return true;
}
catch (boost::property_tree::ptree_error& error)
{
LMS_LOG(RECOMMENDATION, ERROR, "Cannot create network cache: " << error.what());
return false;
}
}
} // namespace
std::optional<som::Network> FeaturesEngineCache::createNetworkFromCacheFile(const std::filesystem::path& path)
{
if (!std::filesystem::exists(path))
return std::nullopt;
try
{
LMS_LOG(RECOMMENDATION, INFO, "Reading network from cache...");
boost::property_tree::ptree root;
boost::property_tree::read_xml(path.string(), root);
som::Coordinate width{ root.get<som::Coordinate>("width") };
som::Coordinate height{ root.get<som::Coordinate>("height") };
std::size_t dimCount{ root.get<std::size_t>("dim_count") };
som::Network res{ width, height, dimCount };
{
som::InputVector weights{ dimCount };
std::size_t i{};
for (const auto& val : root.get_child("weights"))
weights[i++] = val.second.get_value<double>();
res.setDataWeights(weights);
}
for (const auto& node : root.get_child("ref_vectors"))
{
som::Coordinate x{ node.second.get<som::Coordinate>("coord_x") };
som::Coordinate y{ node.second.get<som::Coordinate>("coord_y") };
som::InputVector refVector{ dimCount };
std::size_t i{};
for (const auto& val : node.second.get_child("values"))
refVector[i++] = val.second.get_value<som::InputVector::value_type>();
res.setRefVector({ x, y }, refVector);
}
LMS_LOG(RECOMMENDATION, INFO, "Successfully read network from cache");
return res;
}
catch (boost::property_tree::ptree_error& error)
{
LMS_LOG(RECOMMENDATION, ERROR, "Cannot read network cache: " << error.what());
return std::nullopt;
}
}
bool FeaturesEngineCache::objectPositionToCacheFile(const TrackPositions& trackPositions, const std::filesystem::path& path)
{
try
{
boost::property_tree::ptree root;
for (const auto& [id, positions] : trackPositions)
{
boost::property_tree::ptree node;
node.put("id", id.getValue());
for (const som::Position& position : positions)
{
boost::property_tree::ptree positionNode;
positionNode.put("x", position.x);
positionNode.put("y", position.y);
node.add_child("position.position", positionNode);
}
root.add_child("objects.object", node);
}
boost::property_tree::write_xml(path.string(), root);
return true;
}
catch (boost::property_tree::ptree_error& error)
{
LMS_LOG(RECOMMENDATION, ERROR, "Cannot cache object position: " << error.what());
return false;
}
}
std::optional<FeaturesEngineCache::TrackPositions> FeaturesEngineCache::createObjectPositionsFromCacheFile(const std::filesystem::path& path)
{
try
{
LMS_LOG(RECOMMENDATION, INFO, "Reading object position from cache...");
boost::property_tree::ptree root;
boost::property_tree::read_xml(path.string(), root);
TrackPositions res;
for (const auto& object : root.get_child("objects"))
{
const db::TrackId id{ object.second.get<db::IdType::ValueType>("id") };
for (const auto& position : object.second.get_child("position"))
{
auto x = position.second.get<som::Coordinate>("x");
auto y = position.second.get<som::Coordinate>("y");
res[id].push_back({ x, y });
}
}
LMS_LOG(RECOMMENDATION, INFO, "Successfully read object position from cache");
return res;
}
catch (boost::property_tree::ptree_error& error)
{
LMS_LOG(RECOMMENDATION, ERROR, "Cannot create object position from cache file: " << error.what());
return std::nullopt;
}
}
void FeaturesEngineCache::invalidate()
{
std::filesystem::remove(getCacheNetworkFilePath());
std::filesystem::remove(getCacheTrackPositionsFilePath());
}
std::optional<FeaturesEngineCache> FeaturesEngineCache::read()
{
auto network{ createNetworkFromCacheFile(getCacheNetworkFilePath()) };
if (!network)
return std::nullopt;
auto trackPositions{ createObjectPositionsFromCacheFile(getCacheTrackPositionsFilePath()) };
if (!trackPositions)
return std::nullopt;
return FeaturesEngineCache{ std::move(*network), std::move(*trackPositions) };
}
void FeaturesEngineCache::write() const
{
std::filesystem::create_directories(core::Service<core::IConfig>::get()->getPath("working-dir", "/var/lms") / "cache" / "features");
if (!networkToCacheFile(_network, getCacheNetworkFilePath())
|| !objectPositionToCacheFile(_trackPositions, getCacheTrackPositionsFilePath()))
{
invalidate();
}
}
FeaturesEngineCache::FeaturesEngineCache(som::Network network, TrackPositions trackPositions)
: _network{ std::move(network) }
, _trackPositions{ std::move(trackPositions) }
{
}
} // namespace lms::recommendation
@@ -1,54 +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/>.
*/
#pragma once
#include <filesystem>
#include <unordered_map>
#include "database/objects/TrackId.hpp"
#include "som/Network.hpp"
namespace lms::recommendation
{
class FeaturesEngineCache
{
public:
static void invalidate();
static std::optional<FeaturesEngineCache> read();
void write() const;
private:
using TrackPositions = std::unordered_map<db::TrackId, std::vector<som::Position>>;
FeaturesEngineCache(som::Network network, TrackPositions trackPositions);
static std::optional<som::Network> createNetworkFromCacheFile(const std::filesystem::path& path);
static std::optional<TrackPositions> createObjectPositionsFromCacheFile(const std::filesystem::path& path);
static bool objectPositionToCacheFile(const TrackPositions& trackPositions, const std::filesystem::path& path);
friend class FeaturesEngine;
som::Network _network;
TrackPositions _trackPositions;
};
} // namespace lms::recommendation
@@ -1,92 +0,0 @@
/*
* Copyright (C) 2022 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 "ConsecutiveArtists.hpp"
#include <algorithm>
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
namespace lms::recommendation::PlaylistGeneratorConstraint
{
namespace
{
std::size_t countCommonArtists(const ArtistContainer& artists1, const ArtistContainer& artists2)
{
ArtistContainer intersection;
std::set_intersection(std::cbegin(artists1), std::cend(artists1),
std::cbegin(artists2), std::cend(artists2),
std::back_inserter(intersection));
return intersection.size();
}
} // namespace
ConsecutiveArtists::ConsecutiveArtists(db::IDb& db)
: _db{ db }
{
}
float ConsecutiveArtists::computeScore(const std::vector<db::TrackId>& trackIds, std::size_t trackIndex)
{
assert(!trackIds.empty());
assert(trackIndex <= trackIds.size() - 1);
const ArtistContainer artists{ getArtists(trackIds[trackIndex]) };
constexpr std::size_t rangeSize{ 3 }; // check up to rangeSize tracks before/after the target track
static_assert(rangeSize > 0);
float score{};
for (std::size_t i{ 1 }; i < rangeSize; ++i)
{
if (trackIndex >= i)
score += countCommonArtists(artists, getArtists(trackIds[trackIndex - i])) / static_cast<float>(i);
if (trackIndex + i < trackIds.size())
score += countCommonArtists(artists, getArtists(trackIds[trackIndex + i])) / static_cast<float>(i);
}
return score;
}
ArtistContainer ConsecutiveArtists::getArtists(db::TrackId trackId)
{
using namespace db;
ArtistContainer res;
Session& dbSession{ _db.getTLSSession() };
auto transaction{ dbSession.createReadTransaction() };
const Track::pointer track{ Track::find(dbSession, trackId) };
if (!track)
return res;
res = track->getArtistIds({});
std::sort(std::begin(res), std::end(res));
return res;
}
} // namespace lms::recommendation::PlaylistGeneratorConstraint
@@ -1,45 +0,0 @@
/*
* Copyright (C) 2022 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 "IConstraint.hpp"
namespace lms::db
{
class IDb;
}
namespace lms::recommendation::PlaylistGeneratorConstraint
{
class ConsecutiveArtists : public IConstraint
{
public:
ConsecutiveArtists(db::IDb& db);
~ConsecutiveArtists() override = default;
ConsecutiveArtists(const ConsecutiveArtists&) = delete;
ConsecutiveArtists& operator=(const ConsecutiveArtists&) = delete;
private:
float computeScore(const TrackContainer& trackIds, std::size_t trackIndex) override;
ArtistContainer getArtists(db::TrackId trackId);
db::IDb& _db;
};
} // namespace lms::recommendation::PlaylistGeneratorConstraint
@@ -1,74 +0,0 @@
/*
* Copyright (C) 2022 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 "ConsecutiveReleases.hpp"
#include "database/IDb.hpp"
#include "database/Session.hpp"
#include "database/objects/Release.hpp"
#include "database/objects/Track.hpp"
namespace lms::recommendation::PlaylistGeneratorConstraint
{
ConsecutiveReleases::ConsecutiveReleases(db::IDb& db)
: _db{ db }
{
}
float ConsecutiveReleases::computeScore(const std::vector<db::TrackId>& trackIds, std::size_t trackIndex)
{
assert(!trackIds.empty());
assert(trackIndex <= trackIds.size() - 1);
const db::ReleaseId releaseId{ getReleaseId(trackIds[trackIndex]) };
constexpr std::size_t rangeSize{ 3 }; // check up to rangeSize tracks before/after the target track
static_assert(rangeSize > 0);
float score{};
for (std::size_t i{ 1 }; i < rangeSize; ++i)
{
if ((trackIndex >= i) && getReleaseId(trackIds[trackIndex - i]) == releaseId)
score += (1.F / static_cast<float>(i));
if ((trackIndex + i < trackIds.size()) && getReleaseId(trackIds[trackIndex + i]) == releaseId)
score += (1.F / static_cast<float>(i));
}
return score;
}
db::ReleaseId ConsecutiveReleases::getReleaseId(db::TrackId trackId)
{
using namespace db;
Session& dbSession{ _db.getTLSSession() };
auto transaction{ dbSession.createReadTransaction() };
const Track::pointer track{ Track::find(dbSession, trackId) };
if (!track)
return {};
const Release::pointer release{ track->getRelease() };
if (!release)
return {};
return release->getId();
}
} // namespace lms::recommendation::PlaylistGeneratorConstraint
@@ -1,48 +0,0 @@
/*
* Copyright (C) 2022 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 "IConstraint.hpp"
#include "database/objects/ReleaseId.hpp"
namespace lms::db
{
class IDb;
}
namespace lms::recommendation::PlaylistGeneratorConstraint
{
class ConsecutiveReleases : public IConstraint
{
public:
ConsecutiveReleases(db::IDb& db);
~ConsecutiveReleases() override = default;
ConsecutiveReleases(const ConsecutiveReleases&) = delete;
ConsecutiveReleases& operator=(const ConsecutiveReleases&) = delete;
private:
float computeScore(const std::vector<db::TrackId>& trackIds, std::size_t trackIndex) override;
db::ReleaseId getReleaseId(db::TrackId trackId);
db::IDb& _db;
};
} // namespace lms::recommendation::PlaylistGeneratorConstraint
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,19 +19,21 @@
#pragma once
#include "services/recommendation/Types.hpp"
#include "ITrackCandidateHardConstraint.hpp"
namespace lms::recommendation::PlaylistGeneratorConstraint
namespace lms::recommendation
{
class IConstraint
class DuplicateTrackConstraint : public ITrackCandidateHardConstraint
{
public:
virtual ~IConstraint() = default;
// compute the score of the track at index trackIndex
// 0: best
// 1: worst
// > 1 : violation
virtual float computeScore(const TrackContainer& trackIds, std::size_t trackIndex) = 0;
bool rejects(const TrackCandidateContext& context) const override
{
for (const auto& id : context.selectedTracks)
{
if (id == context.candidateTrackId)
return true;
}
return false;
}
};
} // namespace lms::recommendation::PlaylistGeneratorConstraint
} // namespace lms::recommendation
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,13 +19,15 @@
#pragma once
#include "IConstraint.hpp"
#include "TrackCandidateContext.hpp"
namespace lms::recommendation::PlaylistGeneratorConstraint
namespace lms::recommendation
{
class DuplicateTracks : public IConstraint
class ITrackCandidateHardConstraint
{
private:
float computeScore(const std::vector<db::TrackId>& trackIds, std::size_t trackIndex) override;
public:
virtual ~ITrackCandidateHardConstraint() = default;
virtual bool rejects(const TrackCandidateContext& context) const = 0;
};
} // namespace lms::recommendation::PlaylistGeneratorConstraint
} // namespace lms::recommendation
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -17,15 +17,17 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "DuplicateTracks.hpp"
#pragma once
#include <algorithm>
#include "TrackCandidateContext.hpp"
namespace lms::recommendation::PlaylistGeneratorConstraint
namespace lms::recommendation
{
float DuplicateTracks::computeScore(const std::vector<db::TrackId>& trackIds, std::size_t trackIndex)
class ITrackCandidateSoftConstraint
{
const auto count{ std::count(std::cbegin(trackIds), std::cend(trackIds), trackIds[trackIndex]) };
return count == 1 ? 0 : 1'000;
}
} // namespace lms::recommendation::PlaylistGeneratorConstraint
public:
virtual ~ITrackCandidateSoftConstraint() = default;
virtual float computeScore(const TrackCandidateContext& context) const = 0;
};
} // namespace lms::recommendation
@@ -0,0 +1,34 @@
/*
* 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 "ITrackCandidateSoftConstraint.hpp"
namespace lms::recommendation
{
class InterpolationFitConstraint : public ITrackCandidateSoftConstraint
{
public:
float computeScore(const TrackCandidateContext& context) const override
{
return context.distanceToQuery;
}
};
} // namespace lms::recommendation
@@ -0,0 +1,43 @@
/*
* 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 "ITrackCandidateHardConstraint.hpp"
namespace lms::recommendation
{
// Rejects any candidate whose distance to the query exceeds a given threshold
class MaxDistanceConstraint : public ITrackCandidateHardConstraint
{
public:
explicit MaxDistanceConstraint(float threshold)
: _threshold{ threshold }
{
}
bool rejects(const TrackCandidateContext& context) const override
{
return context.distanceToQuery > _threshold;
}
private:
float _threshold;
};
} // namespace lms::recommendation
@@ -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 "SameArtistConstraint.hpp"
#include <algorithm>
#include "TrackCandidateContext.hpp"
namespace lms::recommendation
{
namespace
{
bool hasCommonArtist(const std::vector<db::ArtistId>& a, const std::vector<db::ArtistId>& b)
{
// Both vectors are sorted
auto ia{ a.cbegin() }, ib{ b.cbegin() };
while (ia != a.cend() && ib != b.cend())
{
if (*ia == *ib)
return true;
if (*ia < *ib)
++ia;
else
++ib;
}
return false;
}
} // namespace
SameArtistConstraint::SameArtistConstraint(const TrackMetadataMap& trackMetadata, std::size_t window)
: _trackMetadata{ trackMetadata }
, _window{ window }
{
}
SameArtistConstraint::~SameArtistConstraint() = default;
float SameArtistConstraint::computeScore(const TrackCandidateContext& context) const
{
const auto it{ _trackMetadata.find(context.candidateTrackId) };
if (it == _trackMetadata.cend() || it->second.artistIds.empty())
return {};
const auto& candidateArtists{ it->second.artistIds };
float score{};
const auto& selected{ context.selectedTracks };
for (std::size_t i{ 1 }; i <= _window && i <= selected.size(); ++i)
{
const auto sit{ _trackMetadata.find(selected[selected.size() - i]) };
if (sit != _trackMetadata.cend() && hasCommonArtist(candidateArtists, sit->second.artistIds))
score += 1.F / static_cast<float>(i);
}
return score;
}
} // namespace lms::recommendation
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,25 +19,24 @@
#pragma once
#include "services/recommendation/IPlaylistGeneratorService.hpp"
#include "services/recommendation/IRecommendationService.hpp"
#include "playlist-constraints/IConstraint.hpp"
#include "ITrackCandidateSoftConstraint.hpp"
#include "TrackMetadata.hpp"
namespace lms::recommendation
{
class PlaylistGeneratorService : public IPlaylistGeneratorService
class SameArtistConstraint : public ITrackCandidateSoftConstraint
{
public:
PlaylistGeneratorService(db::IDb& db, IRecommendationService& recommendationService);
SameArtistConstraint(const TrackMetadataMap& trackMetadata, std::size_t window = 4);
~SameArtistConstraint() override;
SameArtistConstraint(const SameArtistConstraint&) = delete;
SameArtistConstraint& operator=(const SameArtistConstraint&) = delete;
float computeScore(const TrackCandidateContext& context) const override;
private:
TrackContainer extendPlaylist(db::TrackListId tracklistId, std::size_t maxCount) const override;
TrackContainer getTracksFromTrackList(db::TrackListId tracklistId) const;
db::IDb& _db;
IRecommendationService& _recommendationService;
std::vector<std::unique_ptr<PlaylistGeneratorConstraint::IConstraint>> _constraints;
const TrackMetadataMap& _trackMetadata;
const std::size_t _window;
};
} // namespace lms::recommendation
@@ -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/>.
*/
#include "SameReleaseConstraint.hpp"
#include "TrackCandidateContext.hpp"
namespace lms::recommendation
{
SameReleaseConstraint::SameReleaseConstraint(const TrackMetadataMap& trackMetadata, std::size_t window)
: _trackMetadata{ trackMetadata }
, _window{ window }
{
}
SameReleaseConstraint::~SameReleaseConstraint() = default;
float SameReleaseConstraint::computeScore(const TrackCandidateContext& context) const
{
const auto it{ _trackMetadata.find(context.candidateTrackId) };
if (it == _trackMetadata.cend() || !it->second.releaseId.isValid())
return {};
const db::ReleaseId candidateRelease{ it->second.releaseId };
float score{};
const auto& selected{ context.selectedTracks };
for (std::size_t i{ 1 }; i <= _window && i <= selected.size(); ++i)
{
const auto itMetadata{ _trackMetadata.find(selected[selected.size() - i]) };
if (itMetadata != _trackMetadata.cend() && itMetadata->second.releaseId == candidateRelease)
score += 1.F / static_cast<float>(i);
}
return score;
}
} // namespace lms::recommendation
@@ -0,0 +1,42 @@
/*
* 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 "ITrackCandidateSoftConstraint.hpp"
#include "TrackMetadata.hpp"
namespace lms::recommendation
{
class SameReleaseConstraint : public ITrackCandidateSoftConstraint
{
public:
SameReleaseConstraint(const TrackMetadataMap& trackMetadata, std::size_t window = 4);
~SameReleaseConstraint() override;
SameReleaseConstraint(const SameReleaseConstraint&) = delete;
SameReleaseConstraint& operator=(const SameReleaseConstraint&) = delete;
float computeScore(const TrackCandidateContext& context) const override;
private:
const TrackMetadataMap& _trackMetadata;
const std::size_t _window;
};
} // namespace lms::recommendation
@@ -0,0 +1,34 @@
/*
* 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 "ITrackCandidateSoftConstraint.hpp"
namespace lms::recommendation
{
class SmoothTransitionConstraint : public ITrackCandidateSoftConstraint
{
public:
float computeScore(const TrackCandidateContext& context) const override
{
return context.distanceToPrevious;
}
};
} // namespace lms::recommendation
@@ -0,0 +1,35 @@
/*
* 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 "database/objects/TrackId.hpp"
namespace lms::recommendation
{
struct TrackCandidateContext
{
db::TrackId candidateTrackId;
std::span<const db::TrackId> selectedTracks;
float distanceToQuery{};
float distanceToPrevious{};
};
} // namespace lms::recommendation
@@ -0,0 +1,71 @@
/*
* 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 <memory>
#include <vector>
#include "ITrackCandidateHardConstraint.hpp"
#include "ITrackCandidateSoftConstraint.hpp"
namespace lms::recommendation
{
class TrackCandidateEvaluator
{
public:
struct WeightedSoftConstraint
{
std::unique_ptr<ITrackCandidateSoftConstraint> constraint;
float weight{ 1.0F };
};
void addSoftConstraint(std::unique_ptr<ITrackCandidateSoftConstraint> constraint, float weight = 1.0F)
{
_softConstraints.emplace_back(WeightedSoftConstraint{ .constraint = std::move(constraint), .weight = weight });
}
void addHardConstraint(std::unique_ptr<ITrackCandidateHardConstraint> constraint)
{
_hardConstraints.emplace_back(std::move(constraint));
}
bool rejects(const TrackCandidateContext& context) const
{
for (const auto& c : _hardConstraints)
{
if (c->rejects(context))
return true;
}
return false;
}
float score(const TrackCandidateContext& context) const
{
float total{};
for (const auto& item : _softConstraints)
total += item.weight * item.constraint->computeScore(context);
return total;
}
private:
std::vector<WeightedSoftConstraint> _softConstraints;
std::vector<std::unique_ptr<ITrackCandidateHardConstraint>> _hardConstraints;
};
} // namespace lms::recommendation
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2019 Emeric Poupon
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
@@ -19,32 +19,21 @@
#pragma once
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "database/objects/ArtistId.hpp"
#include "database/objects/ReleaseId.hpp"
#include "database/objects/TrackId.hpp"
namespace lms::recommendation
{
using FeatureName = std::string;
using FeatureNames = std::unordered_set<FeatureName>;
using FeatureValue = double;
using FeatureValues = std::vector<FeatureValue>;
using FeatureValuesMap = std::unordered_map<FeatureName, FeatureValues>;
struct FeatureDef
struct TrackMetadata
{
std::size_t nbDimensions{};
db::ReleaseId releaseId; // invalid if track has no release
std::vector<db::ArtistId> artistIds; // sorted; track-level + album-level artists
// Future: db::MediaLibraryId mediaLibraryId;
};
FeatureDef getFeatureDef(const FeatureName& featureName);
FeatureNames getFeatureNames();
struct FeatureSettings
{
double weight{};
};
using FeatureSettingsMap = std::unordered_map<FeatureName, FeatureSettings>;
using TrackMetadataMap = std::unordered_map<db::TrackId, TrackMetadata>;
} // namespace lms::recommendation
@@ -20,7 +20,7 @@
#pragma once
#include <memory>
#include <vector>
#include <span>
#include "core/EnumSet.hpp"
@@ -37,17 +37,29 @@ namespace lms::db
namespace lms::recommendation
{
enum class EngineType
{
None,
Clusters,
AudioSimilarity,
};
class IRecommendationService
{
public:
virtual ~IRecommendationService() = default;
virtual void load() = 0;
virtual bool isEngineTypeSupported(EngineType type) const = 0;
virtual TrackContainer findSimilarTracks(db::TrackListId tracklistId, std::size_t maxCount) const = 0;
virtual TrackContainer findSimilarTracks(const std::vector<db::TrackId>& tracksId, std::size_t maxCount) const = 0;
virtual ReleaseContainer getSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const = 0;
virtual ArtistContainer getSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const = 0;
virtual void requestReload() = 0;
virtual bool isLoaded() const = 0;
virtual EngineType getEngineType() const = 0;
virtual TrackResults findSimilarTracks(db::TrackListId tracklistId, std::size_t maxCount) const = 0;
virtual TrackResults findSimilarTracks(std::span<const db::TrackId> tracksId, std::size_t maxCount) const = 0;
virtual ReleaseResults findSimilarReleases(db::ReleaseId releaseId, std::size_t maxCount) const = 0;
virtual ArtistResults findSimilarArtists(db::ArtistId artistId, core::EnumSet<db::TrackArtistLinkType> linkTypes, std::size_t maxCount) const = 0;
virtual TrackResults findTrackSimilarityPath(db::TrackId startTrackId, db::TrackId endTrackId, std::size_t maxCount) const = 0;
};
std::unique_ptr<IRecommendationService> createRecommendationService(db::IDb& db);
@@ -19,7 +19,7 @@
#pragma once
#include <functional>
#include <vector>
#include "database/objects/ArtistId.hpp"
#include "database/objects/ReleaseId.hpp"
@@ -27,18 +27,17 @@
namespace lms::recommendation
{
struct Progress
template<typename IdType>
struct RecommendationResult
{
std::size_t totalElems{};
std::size_t processedElems{};
IdType id;
float distance{}; // normalized distance in [0, 1]: 0 = most similar, 1 = least similar
};
using ProgressCallback = std::function<void(const Progress&)>;
template<typename IdType>
using ResultContainer = std::vector<IdType>;
using ArtistContainer = ResultContainer<db::ArtistId>;
using ReleaseContainer = ResultContainer<db::ReleaseId>;
using TrackContainer = ResultContainer<db::TrackId>;
using ResultContainer = std::vector<RecommendationResult<IdType>>;
using ArtistResults = ResultContainer<db::ArtistId>;
using ReleaseResults = ResultContainer<db::ReleaseId>;
using TrackResults = ResultContainer<db::TrackId>;
} // namespace lms::recommendation
@@ -0,0 +1,17 @@
add_executable(test-recommendation
ConstraintsTest.cpp
)
target_link_libraries(test-recommendation PRIVATE
lmsrecommendation
GTest::GTest
GTest::gtest_main
)
target_include_directories(test-recommendation PRIVATE
../impl
)
if (NOT CMAKE_CROSSCOMPILING)
gtest_discover_tests(test-recommendation)
endif()
@@ -0,0 +1,240 @@
/*
* 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 "database/objects/ArtistId.hpp"
#include "database/objects/ReleaseId.hpp"
#include "database/objects/TrackId.hpp"
#include "track-selection-constraints/DuplicateTrackConstraint.hpp"
#include "track-selection-constraints/SameArtistConstraint.hpp"
#include "track-selection-constraints/SameReleaseConstraint.hpp"
#include "track-selection-constraints/TrackCandidateContext.hpp"
#include "track-selection-constraints/TrackCandidateEvaluator.hpp"
#include "track-selection-constraints/TrackMetadata.hpp"
using namespace lms;
using namespace lms::recommendation;
namespace
{
const db::TrackId T1{ 1 };
const db::TrackId T2{ 2 };
const db::TrackId T3{ 3 };
const db::TrackId T4{ 4 };
const db::TrackId T5{ 5 };
const db::ArtistId A1{ 10 };
const db::ArtistId A2{ 20 };
const db::ReleaseId R1{ 100 };
const db::ReleaseId R2{ 200 };
} // namespace
TEST(DuplicateTrackConstraint, acceptsNewCandidate)
{
const std::vector<db::TrackId> selected{ T1, T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T3, .selectedTracks = selected };
EXPECT_FALSE(DuplicateTrackConstraint{}.rejects(ctx));
}
TEST(DuplicateTrackConstraint, rejectsAlreadySelected)
{
const std::vector<db::TrackId> selected{ T1, T2, T3 };
const TrackCandidateContext ctx{ .candidateTrackId = T2, .selectedTracks = selected };
EXPECT_TRUE(DuplicateTrackConstraint{}.rejects(ctx));
}
TEST(DuplicateTrackConstraint, acceptsWhenSelectionEmpty)
{
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {} };
EXPECT_FALSE(DuplicateTrackConstraint{}.rejects(ctx));
}
TEST(SameArtistConstraint, zeroScoreWhenNoSharedArtist)
{
const TrackMetadataMap meta{
{ T1, { .releaseId = {}, .artistIds = { A1 } } },
{ T2, { .releaseId = {}, .artistIds = { A2 } } },
};
const SameArtistConstraint constraint{ meta };
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F);
}
TEST(SameArtistConstraint, fullScoreWhenMostRecentMatchesArtist)
{
TrackMetadataMap meta{
{ T1, { .releaseId = {}, .artistIds = { A1 } } },
{ T2, { .releaseId = {}, .artistIds = { A1 } } },
};
const SameArtistConstraint constraint{ meta };
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 1.F);
}
TEST(SameArtistConstraint, halfScoreWhenSecondMostRecentMatchesArtist)
{
TrackMetadataMap meta{
{ T1, { .releaseId = {}, .artistIds = { A1 } } },
{ T2, { .releaseId = {}, .artistIds = { A2 } } },
{ T3, { .releaseId = {}, .artistIds = { A1 } } },
};
const SameArtistConstraint constraint{ meta };
const std::vector<db::TrackId> selected{ T3, T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.5F);
}
TEST(SameArtistConstraint, trackOutsideWindowIsIgnored)
{
TrackMetadataMap meta{
{ T1, { .releaseId = {}, .artistIds = { A1 } } },
{ T2, { .releaseId = {}, .artistIds = { A2 } } },
{ T3, { .releaseId = {}, .artistIds = { A2 } } },
{ T4, { .releaseId = {}, .artistIds = { A2 } } },
{ T5, { .releaseId = {}, .artistIds = { A1 } } }, // outside window=4
};
const SameArtistConstraint constraint{ meta, /*window=*/4 };
TrackMetadataMap meta2{
{ T1, { .releaseId = {}, .artistIds = { A1 } } },
{ T2, { .releaseId = {}, .artistIds = { A2 } } },
{ T3, { .releaseId = {}, .artistIds = { A1 } } },
};
const SameArtistConstraint constraint2{ meta2, /*window=*/1 };
const std::vector<db::TrackId> selected{ T3, T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint2.computeScore(ctx), 0.F);
}
TEST(SameArtistConstraint, zeroScoreWhenCandidateNotInMap)
{
const TrackMetadataMap meta{};
const SameArtistConstraint constraint{ meta };
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F);
}
TEST(SameReleaseConstraint, zeroScoreWhenNoSharedRelease)
{
TrackMetadataMap meta{
{ T1, { .releaseId = R1, .artistIds = {} } },
{ T2, { .releaseId = R2, .artistIds = {} } },
};
const SameReleaseConstraint constraint{ meta };
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F);
}
TEST(SameReleaseConstraint, fullScoreWhenMostRecentMatchesRelease)
{
TrackMetadataMap meta{
{ T1, { .releaseId = R1, .artistIds = {} } },
{ T2, { .releaseId = R1, .artistIds = {} } },
};
const SameReleaseConstraint constraint{ meta };
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 1.F);
}
TEST(SameReleaseConstraint, zeroScoreWhenCandidateHasNoRelease)
{
TrackMetadataMap meta{
{ T1, { .releaseId = {}, .artistIds = {} } },
{ T2, { .releaseId = R1, .artistIds = {} } },
};
const SameReleaseConstraint constraint{ meta };
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(constraint.computeScore(ctx), 0.F);
}
TEST(TrackCandidateEvaluator, hardConstraintRejects)
{
TrackCandidateEvaluator evaluator;
evaluator.addHardConstraint(std::make_unique<DuplicateTrackConstraint>());
const std::vector<db::TrackId> selected{ T1 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_TRUE(evaluator.rejects(ctx));
}
TEST(TrackCandidateEvaluator, noHardConstraintDoesNotReject)
{
TrackCandidateEvaluator evaluator;
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = {} };
EXPECT_FALSE(evaluator.rejects(ctx));
}
TEST(TrackCandidateEvaluator, softConstraintScoreIsWeighted)
{
TrackMetadataMap meta{
{ T1, { .releaseId = R1, .artistIds = {} } },
{ T2, { .releaseId = R1, .artistIds = {} } },
};
TrackCandidateEvaluator evaluator;
evaluator.addSoftConstraint(std::make_unique<SameReleaseConstraint>(meta), 2.F);
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(evaluator.score(ctx), 2.F);
}
TEST(TrackCandidateEvaluator, multipleSoftConstraintsAreAccumulated)
{
TrackMetadataMap meta{
{ T1, { .releaseId = R1, .artistIds = { A1 } } },
{ T2, { .releaseId = R1, .artistIds = { A1 } } },
};
TrackCandidateEvaluator evaluator;
evaluator.addSoftConstraint(std::make_unique<SameReleaseConstraint>(meta), 1.F);
evaluator.addSoftConstraint(std::make_unique<SameArtistConstraint>(meta), 1.F);
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FLOAT_EQ(evaluator.score(ctx), 2.F);
}
TEST(TrackCandidateEvaluator, hardConstraintPassesEvenWithSoftConstraints)
{
TrackMetadataMap meta{
{ T1, { .releaseId = R1, .artistIds = {} } },
{ T2, { .releaseId = R1, .artistIds = {} } },
};
TrackCandidateEvaluator evaluator;
evaluator.addHardConstraint(std::make_unique<DuplicateTrackConstraint>());
evaluator.addSoftConstraint(std::make_unique<SameReleaseConstraint>(meta), 1.F);
const std::vector<db::TrackId> selected{ T2 };
const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected };
EXPECT_FALSE(evaluator.rejects(ctx));
EXPECT_FLOAT_EQ(evaluator.score(ctx), 1.F);
}