Restored recommendations based on acoustic similarities (using musicnn), fixes #301
This commit is contained in:
@@ -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
|
||||
+806
@@ -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
|
||||
+2
-10
@@ -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
|
||||
+4
-11
@@ -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>;
|
||||
}
|
||||
+29
@@ -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>;
|
||||
}
|
||||
+67
@@ -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
|
||||
+14
-12
@@ -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
|
||||
+14
-12
@@ -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
|
||||
+9
-7
@@ -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
|
||||
+11
-9
@@ -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
|
||||
+34
@@ -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
|
||||
+43
@@ -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
|
||||
+74
@@ -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
|
||||
+13
-14
@@ -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
|
||||
+52
@@ -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
|
||||
+42
@@ -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
|
||||
+34
@@ -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
|
||||
+35
@@ -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
|
||||
+71
@@ -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
|
||||
+10
-21
@@ -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
|
||||
+18
-6
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user