Restored recommendations based on acoustic similarities (using musicnn), fixes #301
This commit is contained in:
@@ -90,4 +90,9 @@ namespace lms::scanner
|
||||
{
|
||||
LMS_LOG(DBUPDATER, ERROR, "Failed to parse playlist " << error.path << ": all entries are missing");
|
||||
}
|
||||
|
||||
void ScanErrorLogger::visit(const MusicNNEmbeddingsExtractError& error)
|
||||
{
|
||||
LMS_LOG(DBUPDATER, ERROR, "Failed to extract MusicNN embeddings from " << error.path << ": " << error.errorMsg);
|
||||
}
|
||||
} // namespace lms::scanner
|
||||
@@ -23,21 +23,22 @@
|
||||
|
||||
namespace lms::scanner
|
||||
{
|
||||
class ScanErrorLogger : public scanner::ScanErrorVisitor
|
||||
class ScanErrorLogger : public ScanErrorVisitor
|
||||
{
|
||||
private:
|
||||
void visit(const scanner::ScanError&) override;
|
||||
void visit(const scanner::IOScanError& error) override;
|
||||
void visit(const scanner::AudioFileScanError& error) override;
|
||||
void visit(const scanner::EmbeddedImageScanError& error) override;
|
||||
void visit(const scanner::NoAudioTrackFoundError& error) override;
|
||||
void visit(const scanner::BadAudioDurationError& error) override;
|
||||
void visit(const scanner::ArtistInfoFileScanError& error) override;
|
||||
void visit(const scanner::MissingArtistNameError& error) override;
|
||||
void visit(const scanner::ImageFileScanError& error) override;
|
||||
void visit(const scanner::LyricsFileScanError& error) override;
|
||||
void visit(const scanner::PlayListFileScanError& error) override;
|
||||
void visit(const scanner::PlayListFilePathMissingError& error) override;
|
||||
void visit(const scanner::PlayListFileAllPathesMissingError& error) override;
|
||||
void visit(const ScanError& error) override;
|
||||
void visit(const IOScanError& error) override;
|
||||
void visit(const AudioFileScanError& error) override;
|
||||
void visit(const EmbeddedImageScanError& error) override;
|
||||
void visit(const NoAudioTrackFoundError& error) override;
|
||||
void visit(const BadAudioDurationError& error) override;
|
||||
void visit(const ArtistInfoFileScanError& error) override;
|
||||
void visit(const MissingArtistNameError& error) override;
|
||||
void visit(const ImageFileScanError& error) override;
|
||||
void visit(const LyricsFileScanError& error) override;
|
||||
void visit(const PlayListFileScanError& error) override;
|
||||
void visit(const PlayListFilePathMissingError& error) override;
|
||||
void visit(const PlayListFileAllPathesMissingError& error) override;
|
||||
void visit(const MusicNNEmbeddingsExtractError& error) override;
|
||||
};
|
||||
} // namespace lms::scanner
|
||||
@@ -229,7 +229,7 @@ namespace lms::scanner
|
||||
{
|
||||
constexpr std::size_t writeBatchSize{ 50 };
|
||||
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ namespace lms::scanner
|
||||
{
|
||||
constexpr std::size_t writeBatchSize{ 50 };
|
||||
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ namespace lms::scanner
|
||||
{
|
||||
constexpr std::size_t writeBatchSize{ 5 };
|
||||
|
||||
while ((forceFullBatch && playListFileAssociations.size() >= writeBatchSize) || !playListFileAssociations.empty())
|
||||
while ((forceFullBatch && playListFileAssociations.size() >= writeBatchSize) || (!forceFullBatch && !playListFileAssociations.empty()))
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ namespace lms::scanner
|
||||
{
|
||||
constexpr std::size_t writeBatchSize{ 50 };
|
||||
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ namespace lms::scanner
|
||||
{
|
||||
constexpr std::size_t writeBatchSize{ 50 };
|
||||
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || !imageAssociations.empty())
|
||||
while ((forceFullBatch && imageAssociations.size() >= writeBatchSize) || (!forceFullBatch && !imageAssociations.empty()))
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ namespace lms::scanner
|
||||
constexpr std::size_t writeBatchSize{ 50 };
|
||||
|
||||
std::vector<typename Object::IdType> ids;
|
||||
while ((forceFullBatch && objectIdsToRemove.size() >= writeBatchSize) || !objectIdsToRemove.empty())
|
||||
while ((forceFullBatch && objectIdsToRemove.size() >= writeBatchSize) || (!forceFullBatch && !objectIdsToRemove.empty()))
|
||||
{
|
||||
for (std::size_t i{}; !objectIdsToRemove.empty() && i < writeBatchSize; ++i)
|
||||
{
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
*/
|
||||
|
||||
#include "ScanStepComputeClusterStats.hpp"
|
||||
|
||||
#include "core/ILogger.hpp"
|
||||
|
||||
#include "database/IDb.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/objects/Cluster.hpp"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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 "ScanStepExtractMusicNNEmbeddings.hpp"
|
||||
|
||||
#include <deque>
|
||||
#include <optional>
|
||||
|
||||
#include "core/IJob.hpp"
|
||||
#include "core/IJobScheduler.hpp"
|
||||
#include "core/ILogger.hpp"
|
||||
|
||||
#include "audio/Exception.hpp"
|
||||
#include "audio/IMusicNNEmbeddingExtractor.hpp"
|
||||
#include "audio/MusicNNEmbeddings.hpp"
|
||||
#include "database/IDb.hpp"
|
||||
#include "database/Session.hpp"
|
||||
#include "database/objects/ScanSettings.hpp"
|
||||
#include "database/objects/Track.hpp"
|
||||
#include "database/objects/TrackMusicNNEmbeddings.hpp"
|
||||
#include "services/scanner/ScanErrors.hpp"
|
||||
|
||||
#include "JobQueue.hpp"
|
||||
#include "ScanContext.hpp"
|
||||
#include "ScannerSettings.hpp"
|
||||
#include "TrackLocation.hpp"
|
||||
|
||||
namespace lms::scanner
|
||||
{
|
||||
namespace
|
||||
{
|
||||
struct TrackEmbeddingAssociation
|
||||
{
|
||||
db::TrackId trackId;
|
||||
std::optional<audio::TrackMusicNNEmbeddings> embeddings;
|
||||
};
|
||||
using TrackEmbeddingAssociationContainer = std::deque<TrackEmbeddingAssociation>;
|
||||
|
||||
db::Track::FindParameters createFindTrackParams(db::TrackId lastRetrievedTrackId = {})
|
||||
{
|
||||
db::Track::FindParameters params;
|
||||
params.setHasMusicNNEmbeddings(false);
|
||||
params.setSortMethod(db::TrackSortMethod::Id);
|
||||
params.setLastTrackId(lastRetrievedTrackId);
|
||||
params.setRange(db::Range{ .offset = 0, .size = 1 });
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
bool fetchNextTrackWithoutEmbeddings(db::Session& session, db::TrackId& lastRetrievedTrackId, TrackLocation& trackLocation)
|
||||
{
|
||||
auto transaction{ session.createReadTransaction() };
|
||||
|
||||
const db::Track::FindParameters params{ createFindTrackParams(lastRetrievedTrackId) };
|
||||
|
||||
trackLocation.track = db::TrackId{};
|
||||
trackLocation.trackPath.clear();
|
||||
db::Track::findAbsoluteFilePath(session, params, [&](db::TrackId trackId, const std::filesystem::path& absoluteFilePath) {
|
||||
trackLocation.track = trackId;
|
||||
trackLocation.trackPath = absoluteFilePath;
|
||||
});
|
||||
lastRetrievedTrackId = trackLocation.track;
|
||||
return trackLocation.track.isValid();
|
||||
}
|
||||
|
||||
class ExtractMusicNNEmbeddingsJob : public core::IJob
|
||||
{
|
||||
public:
|
||||
ExtractMusicNNEmbeddingsJob(const audio::IMusicNNEmbeddingExtractor& extractor, const TrackLocation& trackLocation)
|
||||
: _extractor{ extractor }
|
||||
, _trackLocation{ trackLocation }
|
||||
{
|
||||
}
|
||||
~ExtractMusicNNEmbeddingsJob() override = default;
|
||||
ExtractMusicNNEmbeddingsJob(const ExtractMusicNNEmbeddingsJob&) = delete;
|
||||
ExtractMusicNNEmbeddingsJob& operator=(const ExtractMusicNNEmbeddingsJob&) = delete;
|
||||
|
||||
const TrackLocation& getTrackLocation() const { return _trackLocation; }
|
||||
const audio::TrackMusicNNEmbeddings* getEmbeddings() const { return _embeddings ? &_embeddings.value() : nullptr; }
|
||||
std::string_view getErrorMessage() const { return _errorMessage; }
|
||||
|
||||
private:
|
||||
core::LiteralString getName() const override { return "Extract MusicNN Embeddings"; }
|
||||
|
||||
void run() override
|
||||
{
|
||||
try
|
||||
{
|
||||
LMS_LOG(DBUPDATER, DEBUG, "Extracting MusicNN embeddings for " << _trackLocation.trackPath);
|
||||
const auto result{ _extractor.extract(_trackLocation.trackPath) };
|
||||
if (result.patchCount > 0)
|
||||
_embeddings.emplace(result.embeddings);
|
||||
LMS_LOG(DBUPDATER, DEBUG, "MusicNN extraction complete for " << _trackLocation.trackPath << " (" << result.patchCount << " patches)");
|
||||
}
|
||||
catch (const audio::Exception& e)
|
||||
{
|
||||
_errorMessage = e.what();
|
||||
}
|
||||
}
|
||||
|
||||
const audio::IMusicNNEmbeddingExtractor& _extractor;
|
||||
const TrackLocation _trackLocation;
|
||||
std::optional<audio::TrackMusicNNEmbeddings> _embeddings;
|
||||
std::string _errorMessage;
|
||||
};
|
||||
|
||||
void writeEmbedding(db::Session& session, const TrackEmbeddingAssociation& assoc)
|
||||
{
|
||||
db::Track::pointer track{ db::Track::find(session, assoc.trackId) };
|
||||
assert(track);
|
||||
|
||||
std::vector<std::byte> blob(sizeof(audio::TrackMusicNNEmbeddings));
|
||||
audio::trackMusicNNEmbeddingsToBlob(*assoc.embeddings, blob);
|
||||
db::TrackMusicNNEmbeddings::pointer entry{ session.create<db::TrackMusicNNEmbeddings>(track) };
|
||||
entry.modify()->setData(blob);
|
||||
}
|
||||
|
||||
void writeEmbeddings(ScanContext& context, db::Session& session, TrackEmbeddingAssociationContainer& pendingAssocs, bool forceFullBatch)
|
||||
{
|
||||
constexpr std::size_t writeBatchSize{ 10 };
|
||||
|
||||
while ((forceFullBatch && pendingAssocs.size() >= writeBatchSize) || (!forceFullBatch && !pendingAssocs.empty()))
|
||||
{
|
||||
auto transaction{ session.createWriteTransaction() };
|
||||
|
||||
for (std::size_t i{}; !pendingAssocs.empty() && i < writeBatchSize; ++i)
|
||||
{
|
||||
writeEmbedding(session, pendingAssocs.front());
|
||||
pendingAssocs.pop_front();
|
||||
context.stats.featureExtractions += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ScanStepExtractMusicNNEmbeddings::ScanStepExtractMusicNNEmbeddings(InitParams& initParams, const std::filesystem::path& modelPath, std::size_t musicnnMaxPatchCountPerTrack)
|
||||
: ScanStepBase{ initParams }
|
||||
, _embeddingExtractor{ audio::createMusicNNEmbeddingExtractor(modelPath, musicnnMaxPatchCountPerTrack) }
|
||||
{
|
||||
}
|
||||
|
||||
ScanStepExtractMusicNNEmbeddings::~ScanStepExtractMusicNNEmbeddings() = default;
|
||||
|
||||
bool ScanStepExtractMusicNNEmbeddings::needProcess([[maybe_unused]] const ScanContext& context) const
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
void ScanStepExtractMusicNNEmbeddings::process(ScanContext& context)
|
||||
{
|
||||
db::Session& dbSession{ _db.getTLSSession() };
|
||||
|
||||
{
|
||||
const std::string fileIdentifier{ audio::getMusicNNModelIdentifier(_settings.musicnnModelPath) };
|
||||
if (fileIdentifier.empty())
|
||||
{
|
||||
LMS_LOG(DBUPDATER, WARNING, "Cannot identify MusicNN model file, skipping embedding extraction");
|
||||
return;
|
||||
}
|
||||
const std::string identifier{ fileIdentifier + "|" + std::to_string(_settings.musicnnMaxPatchCountPerTrack) };
|
||||
|
||||
auto transaction{ dbSession.createWriteTransaction() };
|
||||
db::ScanSettings::pointer settings{ db::ScanSettings::find(dbSession) };
|
||||
assert(settings);
|
||||
if (settings->getMusicNNModelIdentifier() != identifier)
|
||||
{
|
||||
LMS_LOG(DBUPDATER, INFO, "MusicNN model changed, clearing embeddings");
|
||||
db::TrackMusicNNEmbeddings::removeAll(dbSession);
|
||||
settings.modify()->setMusicNNModelIdentifier(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
db::Track::FindParameters params{ createFindTrackParams() };
|
||||
auto transaction{ dbSession.createReadTransaction() };
|
||||
context.currentStepStats.totalElems = db::Track::getCount(dbSession, params);
|
||||
}
|
||||
|
||||
TrackEmbeddingAssociationContainer pendingAssocs;
|
||||
|
||||
auto processResults{ [&](std::span<std::unique_ptr<core::IJob>> jobs) {
|
||||
if (_abortScan)
|
||||
return;
|
||||
|
||||
for (const auto& job : jobs)
|
||||
{
|
||||
const auto& extractJob{ static_cast<const ExtractMusicNNEmbeddingsJob&>(*job) };
|
||||
|
||||
if (const audio::TrackMusicNNEmbeddings * embeddings{ extractJob.getEmbeddings() })
|
||||
pendingAssocs.push_back(TrackEmbeddingAssociation{ .trackId = extractJob.getTrackLocation().track, .embeddings = *embeddings });
|
||||
else
|
||||
addError<MusicNNEmbeddingsExtractError>(context, extractJob.getTrackLocation().trackPath, extractJob.getErrorMessage());
|
||||
}
|
||||
|
||||
context.currentStepStats.processedElems += jobs.size();
|
||||
writeEmbeddings(context, dbSession, pendingAssocs, true);
|
||||
_progressCallback(context.currentStepStats);
|
||||
} };
|
||||
|
||||
{
|
||||
JobQueue queue{ getJobScheduler(), 50, processResults, 1, 0.85F };
|
||||
|
||||
db::TrackId lastRetrievedTrackId;
|
||||
TrackLocation trackLocation;
|
||||
while (!_abortScan && fetchNextTrackWithoutEmbeddings(dbSession, lastRetrievedTrackId, trackLocation))
|
||||
queue.push(std::make_unique<ExtractMusicNNEmbeddingsJob>(*_embeddingExtractor, trackLocation));
|
||||
}
|
||||
|
||||
writeEmbeddings(context, dbSession, pendingAssocs, false);
|
||||
}
|
||||
} // namespace lms::scanner
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 "ScanStepBase.hpp"
|
||||
|
||||
namespace lms::audio
|
||||
{
|
||||
class IMusicNNEmbeddingExtractor;
|
||||
}
|
||||
|
||||
namespace lms::scanner
|
||||
{
|
||||
class ScanStepExtractMusicNNEmbeddings : public ScanStepBase
|
||||
{
|
||||
public:
|
||||
ScanStepExtractMusicNNEmbeddings(InitParams& initParams, const std::filesystem::path& modelPath, std::size_t maxPatchCountPerTrack);
|
||||
~ScanStepExtractMusicNNEmbeddings() override;
|
||||
ScanStepExtractMusicNNEmbeddings(const ScanStepExtractMusicNNEmbeddings&) = delete;
|
||||
ScanStepExtractMusicNNEmbeddings& operator=(const ScanStepExtractMusicNNEmbeddings&) = delete;
|
||||
|
||||
private:
|
||||
ScanStep getStep() const override { return ScanStep::ExtractMusicNNEmbeddings; }
|
||||
core::LiteralString getStepName() const override { return "Extract MusicNN embeddings"; }
|
||||
bool needProcess(const ScanContext& context) const override;
|
||||
void process(ScanContext& context) override;
|
||||
|
||||
std::unique_ptr<audio::IMusicNNEmbeddingExtractor> _embeddingExtractor;
|
||||
};
|
||||
} // namespace lms::scanner
|
||||
@@ -181,7 +181,7 @@ namespace lms::scanner
|
||||
constexpr std::size_t filesPerScanJob{ 10 };
|
||||
constexpr std::size_t scanQueueMaxSize{ 50 };
|
||||
constexpr std::size_t processFileResultsBatchSize{ 1 };
|
||||
constexpr float drainRatio{ 0.85 };
|
||||
constexpr float drainRatio{ 0.85F };
|
||||
|
||||
std::deque<std::unique_ptr<IFileScanOperation>> operations;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*
|
||||
* LMS is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* LMS is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#include "database/objects/TrackId.hpp"
|
||||
|
||||
namespace lms::scanner
|
||||
{
|
||||
struct TrackLocation
|
||||
{
|
||||
db::TrackId track;
|
||||
std::filesystem::path trackPath;
|
||||
};
|
||||
} // namespace lms::scanner
|
||||
Reference in New Issue
Block a user