diff --git a/.clang-tidy b/.clang-tidy index 2e08e112..ae0feb57 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,5 +1,26 @@ --- -Checks: '-*,bugprone-*,concurrency-*,cppcoreguidelines-*,misc-*,-misc-include-cleaner,-misc-no-recursion,performance*,portability-*,readability-*,-readability-braces-around-statements,-readability-identifier-length,-readability-implicit-bool-conversion' +Checks: > + -*, + bugprone-*, + concurrency-*, + + cppcoreguidelines-*, + -cppcoreguidelines-pro-bounds-constant-array-index, + -cppcoreguidelines-avoid-magic-numbers, + + misc-*, + -misc-include-cleaner, + -misc-no-recursion, + + performance*, + portability-*, + + readability-*, + -readability-braces-around-statements, + -readability-identifier-length, + -readability-implicit-bool-conversion, + -readability-magic-numbers, + WarningsAsErrors: '' HeaderFilterRegex: '' FormatStyle: none diff --git a/.github/workflows/build-freebsd-basic.yml b/.github/workflows/build-freebsd-basic.yml index 265f2578..b55f5e8d 100644 --- a/.github/workflows/build-freebsd-basic.yml +++ b/.github/workflows/build-freebsd-basic.yml @@ -10,11 +10,12 @@ jobs: with: usesh: true prepare: | - pkg install -y cmake pkgconf boost-libs ffmpeg stb libconfig taglib libarchive xxhash wt googletest pugixml pulseaudio + pkg install -y cmake pkgconf boost-libs ffmpeg stb libconfig taglib libarchive xxhash wt googletest onnxruntime pugixml pulseaudio run: | + LMSROOT=$(pwd) mkdir build cd build cmake .. -DCMAKE_UNITY_BUILD=ON -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_BENCHMARKS=OFF make -j$(nproc) - make test + LMS_MUSICNN_MODEL=$LMSROOT/src/libs/audio/models/MSD_musicnn_embedding.onnx make test diff --git a/.github/workflows/build-macos-basic.yml b/.github/workflows/build-macos-basic.yml index 0a85d87a..63226506 100644 --- a/.github/workflows/build-macos-basic.yml +++ b/.github/workflows/build-macos-basic.yml @@ -14,7 +14,7 @@ jobs: - name: Install dependencies run: | brew update - brew install pkg-config cmake boost ffmpeg libconfig taglib libarchive xxhash pugixml googletest openssl git + brew install pkg-config cmake boost ffmpeg libconfig taglib libarchive xxhash pugixml googletest onnxruntime openssl git - name: Build and install Wt run: | @@ -48,4 +48,4 @@ jobs: run: cmake --build build --parallel 2 - name: Test LMS - run: ctest --test-dir build --output-on-failure + run: LMS_MUSICNN_MODEL=$GITHUB_WORKSPACE/src/libs/audio/models/MSD_musicnn_embedding.onnx ctest --test-dir build --output-on-failure diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8ed1c3db..9014f428 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: name: Install dependencies (cpp) run: | sudo apt-get update - sudo apt-get install --yes build-essential cmake libboost-all-dev libconfig++-dev libavcodec-dev libavutil-dev libavformat-dev libstb-dev libtag1-dev libpam0g-dev libgtest-dev libarchive-dev libxxhash-dev libpugixml-dev + sudo apt-get install --yes build-essential cmake libarchive-dev libavcodec-dev libavformat-dev libavutil-dev libboost-all-dev libconfig++-dev libgtest-dev libpam0g-dev libpugixml-dev libstb-dev libtag1-dev libxxhash-dev export WT_VERSION=4.11.3 export WT_INSTALL_PREFIX=/usr git clone https://github.com/emweb/wt.git /tmp/wt diff --git a/CMakeLists.txt b/CMakeLists.txt index ffb277c3..2703d876 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,9 +49,7 @@ if (BUILD_BENCHMARKS) message(STATUS "Building benchmarks") endif() -if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") - add_definitions(-DNDEBUG) -endif() +add_compile_definitions($<$>:NDEBUG>) add_subdirectory(src) @@ -60,4 +58,3 @@ install(DIRECTORY docroot DESTINATION share/lms) install(FILES conf/systemd/default.service DESTINATION share/lms) install(FILES conf/pam/lms DESTINATION share/lms) install(FILES conf/lms.conf DESTINATION share/lms) - diff --git a/Dockerfile-build-alpine b/Dockerfile-build-alpine index c8737514..14f06a52 100644 --- a/Dockerfile-build-alpine +++ b/Dockerfile-build-alpine @@ -1,5 +1,5 @@ FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx -FROM --platform=$BUILDPLATFORM alpine:3.21 +FROM --platform=$BUILDPLATFORM alpine:3.23 ARG BUILD_PACKAGES="\ clang \ @@ -22,6 +22,7 @@ ARG LMS_BUILD_PACKAGES=" \ libarchive-dev \ libconfig-dev \ musl-dev \ + onnxruntime-dev \ pugixml-dev \ pulseaudio-dev \ stb \ @@ -43,4 +44,4 @@ RUN \ PKG_CONFIG_PATH=/$(xx-info)/usr/lib/pkgconfig cmake /tmp/lms/ -DCMAKE_INCLUDE_PATH=/$(xx-info)/usr/include -DCMAKE_UNITY_BUILD=ON -DCMAKE_BUILD_TYPE=${LMS_BUILD_TYPE} $(xx-clang --print-cmake-defines) -DCMAKE_PREFIX_PATH=/$(xx-info)/usr/lib/cmake -DBUILD_TESTING=${BUILD_TESTS} -DBUILD_BENCHMARKS=ON && \ VERBOSE=1 make -j$(nproc) && \ xx-verify src/lms/lms && \ - (xx-info is-cross || make test) + (xx-info is-cross || LMS_MUSICNN_MODEL=/tmp/lms/src/libs/audio/models/MSD_musicnn_embedding.onnx make test) diff --git a/Dockerfile-build-arch b/Dockerfile-build-arch index c73eac24..4803e457 100644 --- a/Dockerfile-build-arch +++ b/Dockerfile-build-arch @@ -12,7 +12,9 @@ ARG BUILD_PACKAGES="\ libconfig \ libpulse \ make \ + onnxruntime-cpu \ pkgconfig \ + protobuf \ pugixml \ stb \ taglib \ @@ -33,4 +35,4 @@ ARG LMS_UNITY_BUILD=ON ARG LMS_IMAGE_BACKEND=stb RUN cmake .. -DCMAKE_BUILD_TYPE=${LMS_BUILD_TYPE} -DCMAKE_UNITY_BUILD=${LMS_UNITY_BUILD} -DLMS_IMAGE_BACKEND=${LMS_IMAGE_BACKEND} -DBUILD_BENCHMARKS=ON RUN VERBOSE=1 make -j$(nproc) -RUN make test +RUN LMS_MUSICNN_MODEL=/tmp/lms/src/libs/audio/models/MSD_musicnn_embedding.onnx make test diff --git a/Dockerfile-release b/Dockerfile-release index 78b536a1..4e94fb67 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -23,6 +23,7 @@ ARG BUILD_PACKAGES=" \ libtool \ libvorbis-dev \ make \ + onnxruntime-dev \ openjpeg-dev \ openssl-dev \ opus-dev \ @@ -119,7 +120,7 @@ RUN \ DIR=/tmp/lms/build && mkdir -p ${DIR} && cd ${DIR} && \ PKG_CONFIG_PATH=/tmp/install/lib/pkgconfig CXXFLAGS="-I${PREFIX}/include" LDFLAGS="-L${PREFIX}/lib -Wl,--rpath-link=${PREFIX}/lib" cmake /tmp/lms/ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_UNITY_BUILD=ON -DCMAKE_INSTALL_PREFIX=${PREFIX} -DCMAKE_PREFIX_PATH=${PREFIX} && \ LD_LIBRARY_PATH=${PREFIX}/lib make -j$(nproc) install && \ - LD_LIBRARY_PATH=${PREFIX}/lib make test && \ + LD_LIBRARY_PATH=${PREFIX}/lib LMS_MUSICNN_MODEL=/tmp/lms/src/libs/audio/models/MSD_musicnn_embedding.onnx make test && \ mkdir -p ${PREFIX}/etc/ && \ cp /tmp/lms/conf/lms.conf ${PREFIX}/etc @@ -162,6 +163,7 @@ ARG RUNTIME_PACKAGES=" \ libpulse \ libvorbis \ libssl3 \ + onnxruntime \ openjpeg \ opus \ pugixml \ @@ -171,7 +173,13 @@ ARG RUNTIME_PACKAGES=" \ ARG LMS_USER=lms ARG LMS_GROUP=lms -RUN apk add --no-cache --update ${RUNTIME_PACKAGES} +# Install packages and remove useless stuff brought by onnxruntime +RUN \ + apk add --no-cache --update ${RUNTIME_PACKAGES} && \ + rm -f \ + /usr/bin/protoc* \ + /usr/bin/onnx* \ + /usr/lib/libprotoc* RUN addgroup -S ${LMS_GROUP} && \ adduser -S -H ${LMS_USER} && \ diff --git a/INSTALL.md b/INSTALL.md index 0e38a00f..387a9dac 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -42,6 +42,7 @@ apt-get install build-essential cmake libboost-program-options-dev libboost-syst __Optional dependencies__: * `libpam0g-dev`: used to handle PAM authentication * `libpulse-dev`: used to output audio via PulseAudio in jukebox mode +* `libonnxruntime-dev`: used to extract MusicNN embeddings from tracks for the recommendation engine __Notes__: * `libstb-dev` can be replaced by `libgraphicsmagick++1-dev` (the latter will likely use more RAM) diff --git a/approot/admin-db.xml b/approot/admin-db.xml index ca7246ac..dfba59bf 100644 --- a/approot/admin-db.xml +++ b/approot/admin-db.xml @@ -5,7 +5,7 @@
- ${export-query-plans-btn class="btn btn-primary"} + ${export-query-profiling-btn class="btn btn-primary"}
diff --git a/approot/admin-scansettings.xml b/approot/admin-scansettings.xml index 48597152..35485e9c 100644 --- a/approot/admin-scansettings.xml +++ b/approot/admin-scansettings.xml @@ -90,12 +90,12 @@
-
diff --git a/approot/artist.xml b/approot/artist.xml index 5dc68238..8c8afcb7 100644 --- a/approot/artist.xml +++ b/approot/artist.xml @@ -37,10 +37,10 @@

${tr:Lms.Explore.tracks}

${tracks class="mb-3"} ${} - ${} -

${tr:Lms.Explore.Artist.similar-artists}

- ${similar-artists class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-6 gx-2 gy-4"} - ${
} + ${} +

${tr:Lms.Explore.Artist.related-artists}

+ ${related-artists class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-6 gx-2 gy-4"} + ${
} diff --git a/approot/messages.xml b/approot/messages.xml index f9484c1b..ba59403b 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -94,13 +94,15 @@ Miscellaneous Monthly Never +This engine is not supported +Recommendation engine +Audio similarity +Tags +None Scan aborted! Scan complete: {1} total files, {2} additions, {3} updates, {4} deletions, {5} duplicates, {6} errors Scan launched! Scan settings -Similarity engine -Tag-based -None Skip playlists that contain tracks from the same album The tag delimiter must not consist solely of spaces Tag parsing @@ -111,6 +113,7 @@ Cannot get track duration Unable to read the image at index {1}: {2} +Cannot extract MusicNN embeddings: {1} Cannot parse artist info file Cannot parse audio file Cannot read file ({1}) @@ -148,11 +151,12 @@ Checking for removed files... {1}% Compacting database... Computing stats... {1}% +Extracting MusicNN embeddings: {1} of {2} files ({3}%) Optimizing database... {1}%... Reconciliating artists: {1} entries... -Reloading similarity engine: {1}%... +Reloading recommendation engine: {1}%... Removing orphaned entries: {1} entries... -Library analysis: {1} files... +Analyzing library: {1} files... Step status Updating library fields: {1} entries @@ -160,7 +164,7 @@ Debug tools -Export query plans +Export query profiling data Database @@ -277,13 +281,13 @@ Appears on Biography -Similar artists +Related artists Copyright Disc {1} Other versions -Similar albums +Related albums Type Album Broadcast diff --git a/approot/messages_es.xml b/approot/messages_es.xml index 81b586f8..80564832 100644 --- a/approot/messages_es.xml +++ b/approot/messages_es.xml @@ -94,13 +94,15 @@ Varios Mensualmente Nunca +Este motor no es compatible +Motor de recomendaciones +Similitud de audio +Etiquetas +Ninguno ¡Escaneo interrumpido! Escaneo terminado : {1} ficheros, {2} añadidos, {3} actualizados, {4} eliminados, {5} duplicados, {6} errores ¡Escaneo comenzado! Opciones de escaneo -Motor de semejanza -Basado en las etiquetas -Ninguno Ignorar las lista de reproducción que contienen pistas del mismo álbum Los delimitadores de las etiquetas no deben consistir solo en espacios Análisis de las etiquetas @@ -111,6 +113,7 @@ No ha sido posible obtener la duración de la pista No se puede leer la imagen en el índice {1} : {2} +No se pueden extraer los embeddings de MusicNN: {1} No ha sido posible analizar el fichero de información sobre el artista No ha sido posible analizar el fichero de audio No ha sido posible leer el fichero ({1}) @@ -148,9 +151,10 @@ Comprobando ficheros eliminados... {1}% Compactando la base de datos... Calculando estadísticas... {1}% +Extracción de embeddings de MusicNN: {1} de {2} archivos ({3}%) Optimizando la base de datos... {1}%... Reconciliando artistas: {1} entradas... -Recargando el motor de similitud: {1}%... +Recargando el motor de recomendaciones: {1}%... Borrando entradas huérfanas: {1} entradas... Análisis de bibliotecas: {1} ficheros... Estado de las etapas @@ -160,7 +164,7 @@ Herramientas de depuración -Exportar planes de consulta +Exportar datos de perfil de consultas Base de datos @@ -277,13 +281,13 @@ Aparece en Biografía -Artistas similares +Artistas relacionados Copyright Disco {1} Otras versiones -Álbumes similares +Álbumes relacionados Tipo Álbum Difusión diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index ceb3c92b..fdebad34 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -94,13 +94,15 @@ Divers Tous les mois Jamais +Ce moteur n'est pas pris en charge +Moteur de recommandation +Similarité audio +Tags +Aucun Scan interrompu ! Scan terminé : {1} fichiers, {2} ajouts, {3} mises à jour, {4} suppressions, {5} duplicatas, {6} erreurs Scan lancé ! Options -Moteur de similarité -Basé sur les tags -Aucun Ignorer les playlists contenant des pistes d'un même album Le délimiteur de tag ne doit pas comporter uniquement des espaces Analyse des tags @@ -111,6 +113,7 @@ Impossible de récupérer la durée de la piste Impossible de lire l'image à l'indice {1} : {2} +Impossible d'extraire les embeddings MusicNN : {1} Impossible d'analyser le fichier d'informations sur l'artiste Impossible d'analyser le fichier audio Impossible de lire le fichier ({1}) @@ -148,9 +151,10 @@ Vérification des fichiers supprimés... {1}% Compactage de la base de données... Calcul des statistiques... {1}% +Extraction des embeddings MusicNN : {1} sur {2} fichiers ({3}%) Optimisation de la base de données... {1}%... Reconciliation des artistes: {1} entrées... -Rechargement du moteur de recommandation : {1}%... +Rechargement du moteur de recommandation : {1}%... Retrait des entrées orphelines: {1} entrées... Analyse des bibliothèques : {1} fichiers... Statut de l'étape @@ -160,7 +164,7 @@ Outils de débogage -Exporter les plans de requête +Exporter les données de profilage des requêtes Base de données @@ -277,13 +281,13 @@ Apparaît dans Biographie -Artistes similaires +Artistes associés Copyright Disque {1} Autres versions -Albums similaires +Albums associés Type Album Diffusion diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 0254d1cc..52ad6b21 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -94,13 +94,15 @@ Varie Mensile Mai +Questo motore non è supportato +Motore di raccomandazione +Similarità audio +Tag +Nessuno Scansione annullata! Scansione completata: {1} file totali, {2} aggiunti, {3} aggiornati, {4} eliminati, {5} duplicati, {6} errati Scansione avviata! Impostazioni di scansione -Motore di similarità -Basato su tag -Nessuno Salta le playlist che contengono brani dello stesso album Il delimitatore del tag non deve consistere esclusivamente di spazi Analisi dei tag @@ -111,6 +113,7 @@ Non sono stato in grado di determinare la durata della traccia Impossibile leggere l'immagine all'indice {1} : {2} +Impossibile estrarre gli embeddings MusicNN: {1} Impossibile analizzare il file delle informazioni sull'artista Impossibile analizzare il file audio Non in grado di leggere il file ({1}) @@ -148,9 +151,10 @@ Controllo file... {1}% Compattazione del database... Calcolo statistiche... {1}% +Estrazione degli embeddings MusicNN: {1} di {2} file ({3}%) Ottimizzazione del database... {1}%... Riconciliazione artisti: {1} voci... -Ricarica motore di tracce simili: {1}%... +Ricaricamento del motore di raccomandazione: {1}%... Rimozione voci orfane: {1} voci... Analisi delle librerie: {1} file... Stato passo @@ -160,7 +164,7 @@ Strumenti di debug -Esporta i piani di query +Esporta i dati di profilazione delle query Database @@ -277,13 +281,13 @@ Appare su Biografia -Artisti simili +Artisti correlati Copyright Disco {1} Altre versioni -Album simili +Album correlati Tipo Album Trasmissione diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 48dd4b72..1ab0d6a6 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -95,13 +95,15 @@ Różne Co miesiąc Nigdy +Ten silnik nie jest obsługiwany +Silnik rekomendacji +Podobieństwo audio +Tagi +Żadna Skanowanie przerwane! Skanowanie ukończone: {1} wszystkich plików, {2} dodane, {3} zmienione, {4} usunięte, {5} duplikaty, {6} błędy Skanowanie uruchomione! Ustawienia skanowania -Metoda sprawdzania podobieństwa -Oparta o znaczniki -Żadna Pomiń playlisty zawierające utwory z tego samego albumu Rozdzielacz nie może się składać z samych białych znaków Analiza tagów @@ -112,6 +114,7 @@ Nie udało się ustalić długości ścieżki Nie można odczytać obrazu pod indeksem {1} : {2} +Nie można wyodrębnić embeddingów MusicNN: {1} Nie można przetworzyć pliku z informacjami o artyście" Nie można przeanalizować pliku audio Nie udało się odczytać pliku ({1}) @@ -161,9 +164,10 @@ Sprawdzanie plików... {1}% Prasowanie bazy danych... Obliczanie statystyk... {1}% +Ekstrakcja embeddingów MusicNN: {1} z {2} plików ({3}%) Optymalizowanie bazy danych... {1}%... Uzgodnianie artystów: {1} wpisów... -Przeładowywanie silnika podobieństw: {1}%... +Przeładowywanie silnika rekomendacji: {1}%... Usuwanie osieroconych wpisów: {1} wpisów... Analiza bibliotek: {1} plik @@ -177,7 +181,7 @@ Narzędzia debugowania -Eksportuj plany zapytań +Eksportuj dane profilowania zapytań Baza danych @@ -303,13 +307,13 @@ Pojawia się na Biografia -Podobni artyści +Powiązani artyści Prawa autorskie Dysk {1} Inne wersje -Podobne albumy +Powiązane albumy Typ Album Audycja diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 810d2c4a..82404325 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -92,13 +92,15 @@ 其他 每月 从不 +此引擎不受支持 +推荐引擎 +音频相似性 +标签 + 扫描已中止! 扫描完成:总文件 {1},添加文件 {2},更新文件 {3},删除文件 {4},重复文件 {5},错误文件 {6} 扫描已完成! 扫描选项 -相似度引擎 -基于标签 - 跳过仅包含同一专辑曲目的播放列表 标签分隔符不能仅由空格组成 标签解析 @@ -109,6 +111,7 @@ 无法获得音轨时间 无法读取索引 {1} 处的图片:{2} +无法提取 MusicNN 嵌入:{1} 无法解析艺术家信息文件 无法解析文件 无法读取文件({1}) @@ -146,9 +149,10 @@ 检查文件中... {1}% 正在压缩数据库... 正在计算统计信息... {1}% +正在提取 MusicNN 嵌入:{1} / {2} 个文件({3}%) 正在优化数据库... {1}%... 整理艺术家:{1} 条记录... -重载相似引擎中 {1}%... +正在重新加载推荐引擎:{1}%... 移除孤立条目:{1} 条记录... 库分析:{1} 个文件... 当前步骤状态 @@ -158,7 +162,7 @@ 调试工具 -导出查询计划 +导出查询分析数据 数据库 @@ -248,13 +252,13 @@ 出现于 简介 -相似歌手 +相关歌手 版权所有 唱片 {1} 其他版本 -相似专辑 +相关专辑 类型 专辑 广播 diff --git a/approot/release.xml b/approot/release.xml index 36afe78e..a542b941 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -45,10 +45,10 @@

${tr:Lms.Explore.Release.other-versions}

${other-versions class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 gx-2 gy-4"} ${} - ${} -

${tr:Lms.Explore.Release.similar-releases}

- ${similar-releases class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 gx-2 gy-4"} - ${
} + ${} +

${tr:Lms.Explore.Release.related-releases}

+ ${related-releases class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 gx-2 gy-4"} + ${
}
diff --git a/conf/lms.conf b/conf/lms.conf index 98a817e3..a4329c4e 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -18,9 +18,9 @@ db-integrity-check = "quick"; # Output db queries on stdout # Use this only for debugging purpose, as this may impact performance db-show-queries = false; -# Record query plans for database queries. +# Record stats for database queries. # Use this only for debugging purposes, as this may impact performance -db-record-query-plans = false; +db-profile-queries = false; # Listen port/addr of the web server listen-port = 5082; @@ -129,6 +129,12 @@ scanner-parser-read-style = "average"; # Number of threads to use for parallelized tasks (e.g., scanning file metadata). 0 means half the number of logical CPUs. scanner-thread-count = 0; +# Path to the musicNN model +musicnn-model-path = "/usr/share/lms/models/MSD_musicnn_embedding.onnx"; + +# Maximum number of non-overlapping patches to extract per track. More patches means better accuracy but also longer processing time. Must be > 0. +musicnn-max-patch-count-per-track = 20; + # Refresh period for podcast feeds in hours (must be greater or equal than 1) podcast-refresh-period-hours = 2; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 79236cc5..dbb78288 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,8 +1,7 @@ + +# Want as many meaningful warnings as possible add_compile_options(-Wall -Wextra -pedantic) add_subdirectory(libs) add_subdirectory(lms) add_subdirectory(tools) - - - diff --git a/src/libs/CMakeLists.txt b/src/libs/CMakeLists.txt index 9a52d2f5..81fa568c 100644 --- a/src/libs/CMakeLists.txt +++ b/src/libs/CMakeLists.txt @@ -2,6 +2,6 @@ add_subdirectory(audio) add_subdirectory(core) add_subdirectory(database) add_subdirectory(image) +add_subdirectory(math) add_subdirectory(services) -add_subdirectory(som) add_subdirectory(subsonic) diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt index 66b3df56..1048503d 100644 --- a/src/libs/audio/CMakeLists.txt +++ b/src/libs/audio/CMakeLists.txt @@ -2,6 +2,7 @@ pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat libswre pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib) pkg_check_modules(PulseAudio IMPORTED_TARGET libpulse) pkg_check_modules(ALSA IMPORTED_TARGET alsa) +pkg_check_modules(OnnxRuntime IMPORTED_TARGET libonnxruntime) if (PulseAudio_FOUND OR ALSA_FOUND) message(STATUS "Audio output available (PulseAudio=${PulseAudio_FOUND}, ALSA=${ALSA_FOUND})") @@ -10,6 +11,7 @@ else() endif() add_library(lmsaudio STATIC + impl/features/MelFilterBank.cpp impl/ffmpeg/AudioFile.cpp impl/ffmpeg/AudioFileInfo.cpp impl/ffmpeg/AudioFileInfoParser.cpp @@ -19,6 +21,7 @@ add_library(lmsaudio STATIC impl/ffmpeg/TagReader.cpp impl/ffmpeg/Transcoder.cpp impl/ffmpeg/Utils.cpp + impl/musicnn/MusicNNEmbeddings.cpp impl/taglib/AudioFileInfo.cpp impl/taglib/AudioFileInfoParser.cpp impl/taglib/ImageReader.cpp @@ -27,6 +30,7 @@ add_library(lmsaudio STATIC impl/utils/PcmDecodeStreamer.cpp impl/AudioFileInfoParser.cpp impl/AudioOutput.cpp + impl/MusicNNEmbeddingExtractorCreator.cpp impl/PcmTypes.cpp impl/TagReader.cpp ) @@ -37,6 +41,7 @@ target_include_directories(lmsaudio INTERFACE target_include_directories(lmsaudio PRIVATE include + impl ${AVCODEC_INCLUDE_DIR} ${AVFORMAT_INCLUDE_DIR} ${AVUTIL_INCLUDE_DIR} @@ -47,6 +52,7 @@ target_link_libraries(lmsaudio PUBLIC ) target_link_libraries(lmsaudio PRIVATE + lmsmath PkgConfig::LIBAV PkgConfig::Taglib ) @@ -54,6 +60,12 @@ target_link_libraries(lmsaudio PRIVATE target_compile_definitions(lmsaudio PRIVATE $<$:LMS_HAVE_PULSEAUDIO> $<$:LMS_HAVE_ALSA> + $<$:LMS_HAVE_ONNX_RUNTIME> + ) + +# Should be safe enough for what we're doing +target_compile_options(lmsaudio PRIVATE + $<$>:-ffast-math> ) if (PulseAudio_FOUND) @@ -80,3 +92,21 @@ if (ALSA_FOUND) ) endif() +if (OnnxRuntime_FOUND) + target_sources(lmsaudio PRIVATE + impl/musicnn/MusicNNEmbeddingExtractor.cpp + impl/musicnn/MusicNNModel.cpp + ) + target_link_libraries(lmsaudio PRIVATE PkgConfig::OnnxRuntime) + message(STATUS "Using ONNX Runtime (${OnnxRuntime_VERSION})") + + install(DIRECTORY models DESTINATION share/lms) +endif() + +if(BUILD_TESTING) + add_subdirectory(test) +endif() + +if (BUILD_BENCHMARKS) + add_subdirectory(bench) +endif() diff --git a/src/libs/core/test/Utils.cpp b/src/libs/audio/bench/Audio.cpp similarity index 79% rename from src/libs/core/test/Utils.cpp rename to src/libs/audio/bench/Audio.cpp index dc673372..8915efc9 100644 --- a/src/libs/core/test/Utils.cpp +++ b/src/libs/audio/bench/Audio.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * @@ -17,10 +17,6 @@ * along with LMS. If not, see . */ -#include +#include -int main(int argc, char** argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} +BENCHMARK_MAIN(); \ No newline at end of file diff --git a/src/libs/audio/bench/CMakeLists.txt b/src/libs/audio/bench/CMakeLists.txt new file mode 100644 index 00000000..f6d97e98 --- /dev/null +++ b/src/libs/audio/bench/CMakeLists.txt @@ -0,0 +1,18 @@ +add_executable(bench-audio + Audio.cpp + ) + +if (OnnxRuntime_FOUND) + target_sources(bench-audio PRIVATE + MusicNNModel.cpp + ) +endif() + +target_include_directories(bench-audio PRIVATE + ../impl + ) + +target_link_libraries(bench-audio PRIVATE + lmsaudio + benchmark + ) diff --git a/src/libs/audio/bench/MusicNNModel.cpp b/src/libs/audio/bench/MusicNNModel.cpp new file mode 100644 index 00000000..0f7e8a5f --- /dev/null +++ b/src/libs/audio/bench/MusicNNModel.cpp @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include +#include + +#include + +#include "musicnn/MusicNNModel.hpp" + +namespace lms::audio::musicnn::benchmarks +{ + namespace + { + std::filesystem::path getMusicNNModelPathFromEnv() + { + const char* p{ std::getenv("LMS_MUSICNN_MODEL") }; + return p ? std::filesystem::path{ p } : std::filesystem::path{}; + } + + std::array makeRandomPatch() + { + std::minstd_rand rng{ 42 }; + std::uniform_real_distribution dist{ 0.F, 1.F }; + std::array patch{}; + for (float& v : patch) + v = dist(rng); + return patch; + } + } // namespace + + static void BM_MusicNNModel_forward(benchmark::State& state) + { + const std::filesystem::path path{ getMusicNNModelPathFromEnv() }; + if (path.empty()) + { + state.SkipWithMessage("LMS_MUSICNN_MODEL not set"); + return; + } + + const MusicNNModel model{ path }; + const auto patch{ makeRandomPatch() }; + + for (auto _ : state) + benchmark::DoNotOptimize(model.forward(patch)); + } + + BENCHMARK(BM_MusicNNModel_forward); + +} // namespace lms::audio::musicnn::benchmarks diff --git a/src/libs/audio/impl/MusicNNEmbeddingExtractorCreator.cpp b/src/libs/audio/impl/MusicNNEmbeddingExtractorCreator.cpp new file mode 100644 index 00000000..8bd4db9e --- /dev/null +++ b/src/libs/audio/impl/MusicNNEmbeddingExtractorCreator.cpp @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "audio/IMusicNNEmbeddingExtractor.hpp" + +#include +#include +#include +#include + +#include "core/XxHash3.hpp" + +#if LMS_HAVE_ONNX_RUNTIME + #include "musicnn/MusicNNEmbeddingExtractor.hpp" +#endif + +namespace lms::audio +{ + bool canExtractMusicNNEmbeddings() + { +#if LMS_HAVE_ONNX_RUNTIME + return true; +#else + return false; +#endif + } + + std::unique_ptr createMusicNNEmbeddingExtractor([[maybe_unused]] const std::filesystem::path& modelPath, std::size_t maxPatchCount) + { +#if LMS_HAVE_ONNX_RUNTIME + return std::make_unique(modelPath, maxPatchCount); +#else + return {}; +#endif + } + + std::string getMusicNNModelIdentifier(const std::filesystem::path& modelPath) + { + std::ifstream file{ modelPath, std::ios::binary }; + if (!file) + return {}; + + core::XxHash3_64 hasher; + constexpr std::size_t readBufSize{ 65536 }; + std::array buf{}; + while (file.read(buf.data(), buf.size()) || file.gcount() > 0) + hasher.update(std::as_bytes(std::span{ buf.data(), static_cast(file.gcount()) })); + + if (!file.eof()) + return {}; + + return std::to_string(hasher.digest()); + } +} // namespace lms::audio diff --git a/src/libs/audio/impl/PcmTypes.cpp b/src/libs/audio/impl/PcmTypes.cpp index e0379efc..64ac9506 100644 --- a/src/libs/audio/impl/PcmTypes.cpp +++ b/src/libs/audio/impl/PcmTypes.cpp @@ -38,4 +38,11 @@ namespace lms::audio throw Exception{ "Unhandled sample type" }; } + namespace helpers + { + std::size_t sampleCountToByteCount(std::size_t sampleCount, PcmSampleType sampleType, unsigned channelCount) + { + return sampleCount * audio::getSampleSize(sampleType) * channelCount; + } + } // namespace helpers } // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/impl/features/MelFilterBank.cpp b/src/libs/audio/impl/features/MelFilterBank.cpp new file mode 100644 index 00000000..ebd42754 --- /dev/null +++ b/src/libs/audio/impl/features/MelFilterBank.cpp @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "MelFilterBank.hpp" + +#include +#include +#include +#include + +#include "audio/Exception.hpp" + +namespace lms::audio::features +{ + float freqToMel(float freq) + { + return 2595.F * std::log10(1.0F + freq / 700.F); + } + + float melToFreq(float mel) + { + return 700.F * (std::pow(10.F, mel / 2595.F) - 1.F); + } + + MelFilterBank::MelFilterBank(std::vector&& _filters, std::size_t binCount) + : _filters{ std::move(_filters) } + , _binCount{ binCount } + { + } + + const MelFilterBank::Filter& MelFilterBank::getFilter(std::size_t m) const + { + return _filters.at(m); + } + + std::size_t MelFilterBank::getFilterCount() const + { + return _filters.size(); + } + + std::size_t MelFilterBank::getBinCount() const + { + return _binCount; + } + + float MelFilterBank::computeEnergy(std::size_t m, std::span input) const + { + if (input.size() != _binCount) + throw Exception{ "Input size must be equal to the number of bins" }; + + const auto& filter{ _filters.at(m) }; + assert(filter.leftBinIndex + filter.weights.size() <= input.size()); + + float energy{}; + std::size_t bin{ filter.leftBinIndex }; + for (std::size_t i{}, n = filter.weights.size(); i < n; ++i, ++bin) + energy += input[bin] * filter.weights[i]; + + return energy; + } + + MelFilterBank computeMelFilterBank(std::size_t nfft, std::size_t sampleRate, std::size_t filterCount, float fMin, float fMax) + { + const float nyquist{ sampleRate / 2.F }; + const float effectiveFMin{ (fMin <= 0.F) ? 0.F : fMin }; + const float effectiveFMax{ (fMax <= 0.F || fMax > nyquist) ? nyquist : fMax }; + const float melMin{ freqToMel(effectiveFMin) }; + const float melMax{ freqToMel(effectiveFMax) }; + + // 1. mel points + std::vector melPoints(filterCount + 2); + for (std::size_t i{}; i < melPoints.size(); ++i) + melPoints[i] = melMin + i * (melMax - melMin) / (filterCount + 1); + + // 2. mel -> Hz + std::vector freqs(filterCount + 2); + std::transform(melPoints.begin(), melPoints.end(), freqs.begin(), melToFreq); + + // 3. Hz -> bins + std::vector bins(filterCount + 2); + for (std::size_t i{}; i < bins.size(); ++i) + bins[i] = static_cast(std::floor(nfft * freqs[i] / sampleRate)); + + // 4. fix duplicates + for (std::size_t i{ 1 }; i < bins.size(); ++i) + { + if (bins[i] <= bins[i - 1]) + bins[i] = bins[i - 1] + 1; + } + + // 5. build filters + const std::size_t binCount{ nfft / 2 + 1 }; + std::vector filters{ filterCount }; + + for (std::size_t m{}; m < filterCount; ++m) + { + const std::size_t left{ bins[m] }; + const std::size_t center{ bins[m + 1] }; + const std::size_t right{ bins[m + 2] }; + + std::vector filterWeights; + filterWeights.reserve(right - left); + + // rising edge of the triangle + for (std::size_t k{ left }; k < center; ++k) + filterWeights.push_back(float(k - left) / (center - left)); + + // falling edge of the triangle + for (std::size_t k{ center }; k < right; ++k) + filterWeights.push_back(float(right - k) / (right - center)); + + assert(filterWeights.size() == right - left); + + // normalize the filter to sum = 1 + const float sum{ std::accumulate(filterWeights.begin(), filterWeights.end(), 0.F) }; + if (sum > 0.F) + { + for (float& weight : filterWeights) + weight /= sum; + } + + filters[m] = MelFilterBank::Filter{ std::move(filterWeights), left }; + } + + return MelFilterBank{ std::move(filters), binCount }; + } +} // namespace lms::audio::features \ No newline at end of file diff --git a/src/libs/audio/impl/features/MelFilterBank.hpp b/src/libs/audio/impl/features/MelFilterBank.hpp new file mode 100644 index 00000000..c82dfbcb --- /dev/null +++ b/src/libs/audio/impl/features/MelFilterBank.hpp @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +namespace lms::audio::features +{ + float freqToMel(float freq); + float melToFreq(float mel); + + struct MelFilterBank + { + struct Filter + { + std::vector weights; // normalized so that the sum of weights equals 1.0 + std::size_t leftBinIndex; // index of the leftmost FFT bin covered by the filter + }; + + // binCount is the number of FFT bins (nfft/2 + 1) that the filters can cover + MelFilterBank(std::vector&& _filters, std::size_t binCount); + + const Filter& getFilter(std::size_t m) const; + std::size_t getFilterCount() const; + std::size_t getBinCount() const; + + float computeEnergy(std::size_t m, std::span input) const; + + private: + const std::vector _filters; + const std::size_t _binCount; + }; + + // Each filter covers a range of FFT bins and is normalized so that the sum of its weights equals 1.0 + // Each filter stores only its non-zero triangular region (sparse representation) + // fMin/fMax: frequency range in Hz. Defaults (0.f, 0.f) span from 0 to Nyquist. + MelFilterBank computeMelFilterBank(size_t nfft, size_t sampleRate, size_t filterCount, float fMin = 0.F, float fMax = 0.F); +} // namespace lms::audio::features \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/AudioFile.cpp b/src/libs/audio/impl/ffmpeg/AudioFile.cpp index 33b1a50f..325c0054 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFile.cpp +++ b/src/libs/audio/impl/ffmpeg/AudioFile.cpp @@ -285,7 +285,7 @@ namespace lms::audio::ffmpeg bool AudioFile::hasAttachedPictures() const { - for (std::size_t i = 0; i < _context->nb_streams; ++i) + for (std::size_t i{}; i < _context->nb_streams; ++i) { if (_context->streams[i]->disposition & AV_DISPOSITION_ATTACHED_PIC) return true; @@ -304,7 +304,7 @@ namespace lms::audio::ffmpeg { AV_CODEC_ID_PPM, "image/x-portable-pixmap" }, }; - for (std::size_t i = 0; i < _context->nb_streams; ++i) + for (std::size_t i{}; i < _context->nb_streams; ++i) { AVStream* avstream = _context->streams[i]; diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp index 6937c3ce..499db0e2 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp @@ -127,6 +127,14 @@ namespace lms::audio::ffmpeg } } + { + _estimatedDuration = std::chrono::milliseconds{ _context->duration == AV_NOPTS_VALUE ? 0 : _context->duration / AV_TIME_BASE * 1'000 }; + if (_estimatedDuration > offset) + _estimatedDuration = _estimatedDuration - std::chrono::duration_cast(offset); + else + _estimatedDuration = {}; + } + _decoderContext = AVCodecContextPtr{ ::avcodec_alloc_context3(decoder) }; if (!_decoderContext) throw Exception{ "Cannot allocate decoder context" }; @@ -225,7 +233,7 @@ namespace lms::audio::ffmpeg else { std::array outData{}; - for (size_t i = 0; i < outputChannelBuffers.size(); ++i) + for (std::size_t i{}; i < outputChannelBuffers.size(); ++i) outData[i] = reinterpret_cast(outputChannelBuffers[i].data()); // Resample decoded audio @@ -266,6 +274,11 @@ namespace lms::audio::ffmpeg return _finished; } + std::chrono::milliseconds PcmDecoder::getEstimatedDuration() const + { + return _estimatedDuration; + } + std::size_t PcmDecoder::computeSampleCountPerChannel(std::span outputChannelBuffers) const { if (_parameters.planar) @@ -343,7 +356,7 @@ namespace lms::audio::ffmpeg std::size_t PcmDecoder::drainResampler(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) { std::array outData{}; - for (size_t i = 0; i < outputChannelBuffers.size(); ++i) + for (std::size_t i{}; i < outputChannelBuffers.size(); ++i) outData[i] = reinterpret_cast(outputChannelBuffers[i].data()); const int outSampleCount{ ::swr_convert(_resampleContext.get(), diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp index ec2e0eb6..8c7d5c3f 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp @@ -40,6 +40,8 @@ namespace lms::audio::ffmpeg std::size_t readSamples(std::span outputChannelBuffers) override; bool finished() const override; + std::chrono::milliseconds getEstimatedDuration() const override; + std::size_t computeSampleCountPerChannel(std::span outputChannelBuffers) const; void feedDecoder(); std::size_t drainResampler(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel); @@ -52,6 +54,7 @@ namespace lms::audio::ffmpeg bool _draining{}; AVFormatContextPtr _context; + std::chrono::milliseconds _estimatedDuration{}; int _inputStreamIndex{}; AVCodecContextPtr _decoderContext; AVFramePtr _decodedFrame; diff --git a/src/libs/audio/impl/ffmpeg/Utils.cpp b/src/libs/audio/impl/ffmpeg/Utils.cpp index 43ffb716..68da455f 100644 --- a/src/libs/audio/impl/ffmpeg/Utils.cpp +++ b/src/libs/audio/impl/ffmpeg/Utils.cpp @@ -62,7 +62,7 @@ namespace lms::audio::ffmpeg::utils void avLogCallback(void*, int level, const char* fmt, va_list vl) { - if (!core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) + if (!core::Service::get() || core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) return; if (level > AV_LOG_WARNING) diff --git a/src/libs/audio/impl/musicnn/MusicNNEmbeddingExtractor.cpp b/src/libs/audio/impl/musicnn/MusicNNEmbeddingExtractor.cpp new file mode 100644 index 00000000..64f8d988 --- /dev/null +++ b/src/libs/audio/impl/musicnn/MusicNNEmbeddingExtractor.cpp @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "MusicNNEmbeddingExtractor.hpp" + +#include +#include +#include +#include +#include + +#include "audio/Exception.hpp" +#include "audio/IMusicNNEmbeddingExtractor.hpp" +#include "math/StatsAccumulator.hpp" +#include "musicnn/MusicNNModel.hpp" + +namespace lms::audio::musicnn +{ + namespace + { + constexpr float minMeaningfulPatchRms{ 0.003F }; + + template + FloatType computeRms(std::span samples) + { + const FloatType sumSq{ std::transform_reduce(samples.begin(), samples.end(), FloatType{}, std::plus<>{}, [](FloatType s) { return s * s; }) }; + return std::sqrt(sumSq / static_cast(samples.size())); + } + + // We want something like this: + // gap patch(0) gap patch(1) gap patch(maxPatchCount) gap + std::size_t computePatchGap(std::size_t totalFrameCount, std::size_t patchFrameCount, std::size_t maxPatchCount) + { + assert(maxPatchCount > 0); + assert(patchFrameCount > 0); + + const std::size_t patchCount{ std::min(maxPatchCount, totalFrameCount / patchFrameCount) }; + if (patchCount == 0) + return 0; + + return (totalFrameCount - patchCount * patchFrameCount) / (patchCount + 1); + } + } // namespace + + // Accumulates one 187-frame MusicNN mel patch + class MusicNNEmbeddingExtractor::PatchAccumulator + { + public: + void addMelRow(std::span melRow, float frameRms) + { + assert(_frameCount < patchFrameCount); + const std::size_t offset{ _frameCount * melBandCount }; + std::copy(melRow.begin(), melRow.end(), _melMatrix.begin() + static_cast(offset)); + _rmsAccum += frameRms; + ++_frameCount; + } + + void reset() noexcept + { + _frameCount = {}; + _rmsAccum = {}; + } + + [[nodiscard]] bool complete() const { return _frameCount == patchFrameCount; } + + [[nodiscard]] bool meaningful() const + { + return (_frameCount > 0) && ((_rmsAccum / static_cast(_frameCount)) >= minMeaningfulPatchRms); + } + + [[nodiscard]] std::span data() const + { + return _melMatrix; + } + + private: + std::size_t _frameCount{}; + float _rmsAccum{}; + std::array _melMatrix; + }; + + MusicNNEmbeddingExtractor::MusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount) + : _melFilterBank{ features::computeMelFilterBank(fftSize, sampleRate, melBandCount, melFMin, melFMax) } + , _model{ modelPath } + , _maxPatchCount{ maxPatchCount } + { + static_assert(MusicNNEmbeddingExtractor::windowSize == MusicNNEmbeddingExtractor::fftSize); + if (_maxPatchCount <= 0) + throw audio::Exception{ "MusicNN embedding extractor: max patch count must be > 0" }; + } + + IMusicNNEmbeddingExtractor::ExtractionResult MusicNNEmbeddingExtractor::extract(const std::filesystem::path& audioFile) const + { + auto frameDecoder{ std::make_unique(audioFile, + PcmParameters{ .channelCount = 1, + .sampleRate = static_cast(sampleRate), + .sampleType = PcmSampleType::Float32, + .byteOrder = std::endian::native, + .planar = false }, + frameHopSamples) }; + + std::array logMelRow{}; + std::array, decltype(_model)::outputSize> embeddingAccumulators; + ExtractionResult result; + const std::size_t estimatedFrameCount{ frameDecoder->getEstimatedFrameCount() }; + + // Fallback: use a gap of two patch lengths if the frame count is unknown + const std::size_t patchGapFrameCount{ estimatedFrameCount ? computePatchGap(frameDecoder->getEstimatedFrameCount(), patchFrameCount, _maxPatchCount) : (2 * patchFrameCount) }; + const auto patchAccumulator{ std::make_unique() }; + + while (true) + { + patchAccumulator->reset(); + + const auto onFrame{ [&](const FrameDecoder::SpectralFrameView& frame) { + // MusicNN log compression: log10(10000 * mel + 1) + for (std::size_t m{}; m < melBandCount; ++m) + { + const float energy{ _melFilterBank.computeEnergy(m, std::span(frame.powerSpectrum)) }; + logMelRow[m] = std::log10(10000.F * energy + 1.F); + } + + const float rms{ computeRms(frame.rawSamples.subspan(0, frameHopSamples)) }; + patchAccumulator->addMelRow(logMelRow, rms); + } }; + + if (patchGapFrameCount > 0 && frameDecoder->skipFrames(patchGapFrameCount) == 0) + break; + + if (frameDecoder->decodeFrames(patchFrameCount, onFrame) < patchFrameCount) + break; + + assert(patchAccumulator->complete()); + + if (!patchAccumulator->meaningful()) + continue; + + const auto embedding{ _model.forward(patchAccumulator->data()) }; + for (std::size_t d{}; d < embedding.size(); ++d) + embeddingAccumulators[d].add(embedding[d]); + ++result.patchCount; + } + + if (result.patchCount > 0) + { + for (std::size_t d{}; d < decltype(_model)::outputSize; ++d) + result.embeddings.mean.values[d] = embeddingAccumulators[d].getMean(); + } + + return result; + } +} // namespace lms::audio::musicnn diff --git a/src/libs/audio/impl/musicnn/MusicNNEmbeddingExtractor.hpp b/src/libs/audio/impl/musicnn/MusicNNEmbeddingExtractor.hpp new file mode 100644 index 00000000..ad5734b0 --- /dev/null +++ b/src/libs/audio/impl/musicnn/MusicNNEmbeddingExtractor.hpp @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "audio/IMusicNNEmbeddingExtractor.hpp" + +#include "MusicNNModel.hpp" +#include "features/MelFilterBank.hpp" +#include "utils/PcmSpectralFrameDecoder.hpp" + +namespace lms::audio::musicnn +{ + class MusicNNEmbeddingExtractor : public IMusicNNEmbeddingExtractor + { + public: + MusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount); + ~MusicNNEmbeddingExtractor() override = default; + + MusicNNEmbeddingExtractor(const MusicNNEmbeddingExtractor&) = delete; + MusicNNEmbeddingExtractor& operator=(const MusicNNEmbeddingExtractor&) = delete; + + private: + [[nodiscard]] ExtractionResult extract(const std::filesystem::path& audioFile) const override; + + // MusicNN signal processing constants (from musicnn/configuration.py and musicnn_torch.py) + static constexpr std::size_t sampleRate{ 16'000 }; + static constexpr std::size_t windowSize{ 512 }; // 512-sample Hann window (32 ms) + static constexpr std::size_t fftSize{ 512 }; + static constexpr std::size_t frameHopSamples{ 256 }; // 16 ms hop (matches FFT_HOP in musicnn) + static constexpr std::size_t melBandCount{ 96 }; + static constexpr float melFMin{ 0.F }; + static constexpr float melFMax{ 8'000.F }; + static constexpr std::size_t patchFrameCount{ MusicNNModel::inputFrames }; // 187 frames = 3 s + + class PatchAccumulator; + + using FrameDecoder = PcmSpectralFrameDecoder<512, float>; + const features::MelFilterBank _melFilterBank; + const MusicNNModel _model; + const std::size_t _maxPatchCount; + }; +} // namespace lms::audio::musicnn diff --git a/src/libs/audio/impl/musicnn/MusicNNEmbeddings.cpp b/src/libs/audio/impl/musicnn/MusicNNEmbeddings.cpp new file mode 100644 index 00000000..59eb1cbb --- /dev/null +++ b/src/libs/audio/impl/musicnn/MusicNNEmbeddings.cpp @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "audio/MusicNNEmbeddings.hpp" + +#include +#include +#include + +#include "audio/Exception.hpp" + +namespace lms::audio +{ + static_assert(sizeof(float) == 4); + static_assert(std::numeric_limits::is_iec559); + + namespace + { + constexpr uint32_t byteswap32(uint32_t x) + { + return (x >> 24) | ((x >> 8) & 0x0000FF00u) | ((x << 8) & 0x00FF0000u) | (x << 24); + } + + void writeFloats(std::span data, std::span blob) + { + if (blob.size() < data.size() * sizeof(uint32_t)) + throw Exception{ "Buffer too small to write MusicNN embeddings" }; + + for (std::size_t i{}; i < data.size(); ++i) + { + uint32_t bits{ std::bit_cast(data[i]) }; + if constexpr (std::endian::native == std::endian::little) + bits = byteswap32(bits); + std::memcpy(blob.data() + i * 4, &bits, 4); + } + } + + void readFloats(std::span blob, std::span data) + { + if (blob.size() < data.size() * sizeof(uint32_t)) + throw Exception{ "Buffer too small to read MusicNN embeddings" }; + + for (std::size_t i{}; i < data.size(); ++i) + { + uint32_t bits{}; + std::memcpy(&bits, blob.data() + i * 4, 4); + if constexpr (std::endian::native == std::endian::little) + bits = byteswap32(bits); + data[i] = std::bit_cast(bits); + } + } + } // anonymous namespace + + void trackMusicNNEmbeddingsToBlob(const TrackMusicNNEmbeddings& embeddings, std::span buffer) + { + if (buffer.size() < sizeof(TrackMusicNNEmbeddings)) + throw Exception{ "Buffer too small to write TrackMusicNNEmbeddings" }; + + writeFloats(embeddings.mean.values, buffer.subspan(0, MusicNNEmbedding::size * sizeof(float))); + } + + void trackMusicNNEmbeddingsFromBlob(std::span buffer, TrackMusicNNEmbeddings& embeddings) + { + if (buffer.size() < sizeof(TrackMusicNNEmbeddings)) + throw Exception{ "Buffer too small to read TrackMusicNNEmbeddings" }; + + readFloats(buffer.subspan(0, MusicNNEmbedding::size * sizeof(float)), embeddings.mean.values); + } +} // namespace lms::audio diff --git a/src/libs/audio/impl/musicnn/MusicNNModel.cpp b/src/libs/audio/impl/musicnn/MusicNNModel.cpp new file mode 100644 index 00000000..7222a3c1 --- /dev/null +++ b/src/libs/audio/impl/musicnn/MusicNNModel.cpp @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "MusicNNModel.hpp" + +#include + +#include + +#include "audio/Exception.hpp" + +namespace lms::audio::musicnn +{ + namespace + { + // Shape of the ONNX model's single input tensor: [batch=1, T=187, mel=96] + const std::array inputShape{ 1, + static_cast(MusicNNModel::inputFrames), + static_cast(MusicNNModel::inputBands) }; + + // Shape of the ONNX model's single output tensor: [batch=1, embedding=200] + const std::array outputShape{ 1, + static_cast(MusicNNModel::outputSize) }; + + constexpr const char* inputName{ "mel_patch" }; + constexpr const char* outputName{ "embedding" }; + } // namespace + + struct MusicNNModel::Impl + { + Ort::Env env; + Ort::SessionOptions sessionOptions; + Ort::Session session; + Ort::MemoryInfo memoryInfo; + + explicit Impl(const std::filesystem::path& onnxPath) + : env{ ORT_LOGGING_LEVEL_ERROR, "MusicNN" } + , session{ [&]() -> Ort::Session { + sessionOptions.SetIntraOpNumThreads(1); + sessionOptions.SetInterOpNumThreads(1); + return Ort::Session{ env, onnxPath.c_str(), sessionOptions }; + }() } + , memoryInfo{ Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault) } + { + } + }; + + MusicNNModel::MusicNNModel(const std::filesystem::path& onnxPath) + { + try + { + _impl = std::make_unique(onnxPath); + } + catch (const Ort::Exception& e) + { + throw audio::Exception{ std::string{ "Failed to load ONNX model '" } + onnxPath.string() + "': " + e.what() }; + } + } + + MusicNNModel::~MusicNNModel() = default; + + std::array MusicNNModel::forward( + std::span melPatch) const + { + Ort::Value inputTensor{ Ort::Value::CreateTensor( + _impl->memoryInfo, + const_cast(melPatch.data()), // safe cast + melPatch.size(), + inputShape.data(), + inputShape.size()) }; + + std::array result{}; + Ort::Value outputTensor{ Ort::Value::CreateTensor( + _impl->memoryInfo, + result.data(), + result.size(), + outputShape.data(), + outputShape.size()) }; + + try + { + auto inputNames{ std::to_array({ inputName }) }; + auto outputNames{ std::to_array({ outputName }) }; + _impl->session.Run(Ort::RunOptions{ nullptr }, + inputNames.data(), &inputTensor, 1, + outputNames.data(), &outputTensor, 1); + } + catch (const Ort::Exception& e) + { + throw audio::Exception{ std::string{ "ONNX inference failed: " } + e.what() }; + } + + return result; + } +} // namespace lms::audio::musicnn diff --git a/src/libs/audio/impl/musicnn/MusicNNModel.hpp b/src/libs/audio/impl/musicnn/MusicNNModel.hpp new file mode 100644 index 00000000..e21562bc --- /dev/null +++ b/src/libs/audio/impl/musicnn/MusicNNModel.hpp @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace lms::audio::musicnn +{ + // The ONNX model must be exported with: + // input "mel_patch" shape [1, 1, 187, 96] + // output "embedding" shape [1, 200] + // + // Export script: tools/musicnn/export_onnx.py + class MusicNNModel + { + public: + explicit MusicNNModel(const std::filesystem::path& onnxPath); + ~MusicNNModel(); + + MusicNNModel(const MusicNNModel&) = delete; + MusicNNModel& operator=(const MusicNNModel&) = delete; + + static inline constexpr std::size_t inputFrames{ 187 }; + static inline constexpr std::size_t inputBands{ 96 }; + static inline constexpr std::size_t outputSize{ 200 }; + + [[nodiscard]] std::array forward(std::span melPatch) const; + + private: + // Pimpl: keep ORT headers out of translation units that include this header. + struct Impl; + std::unique_ptr _impl; + }; +} // namespace lms::audio::musicnn diff --git a/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp b/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp index 8e017c91..8051293c 100644 --- a/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp +++ b/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include diff --git a/src/libs/audio/impl/utils/PcmSpectralFrameDecoder.hpp b/src/libs/audio/impl/utils/PcmSpectralFrameDecoder.hpp new file mode 100644 index 00000000..23170ffe --- /dev/null +++ b/src/libs/audio/impl/utils/PcmSpectralFrameDecoder.hpp @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/AlignedHeapArray.hpp" + +#include "audio/IPcmDecoder.hpp" +#include "audio/PcmTypes.hpp" +#include "math/FFT.hpp" +#include "math/Window.hpp" + +namespace lms::audio +{ + // Stateful PCM frame decoder that applies a Hann window + FFT per frame. + template + class PcmSpectralFrameDecoder + { + static_assert(std::has_single_bit(WindowSize), "WindowSize must be a power of two"); + + public: + using FFTPlan = math::FixedRealFFTPlan; + static constexpr std::size_t spectrumSize{ FFTPlan::getOutputSize() }; + + PcmSpectralFrameDecoder(const std::filesystem::path& audioFile, const PcmParameters& params, std::size_t hopSize) + : PcmSpectralFrameDecoder{ createPcmDecoder(audioFile, {}, params), hopSize } + { + } + ~PcmSpectralFrameDecoder() = default; + + explicit PcmSpectralFrameDecoder(std::unique_ptr decoder, std::size_t hopSize) + : _pcmParams{ decoder->getParameters() } + , _hopSize{ hopSize } + , _powerScale{ FloatType{ 1 } / (_window.energy() * static_cast(WindowSize)) } + , _decoder{ std::move(decoder) } + , _samplesBuffer(bufferFrameCount * _hopSize + WindowSize) + , _bufferedSampleCount{ WindowSize / 2 } // first analysis frame centered on sample 0, matching librosa center=True semantics. + { + assert(_hopSize > 0); + } + + PcmSpectralFrameDecoder(const PcmSpectralFrameDecoder&) = delete; + PcmSpectralFrameDecoder& operator=(const PcmSpectralFrameDecoder&) = delete; + + std::size_t hopSize() const noexcept { return _hopSize; } + const PcmParameters& pcmParameters() const noexcept { return _pcmParams; } + + std::size_t getEstimatedFrameCount() const + { + const auto duration{ _decoder->getEstimatedDuration() }; + if (duration <= std::chrono::milliseconds::zero()) + return 0; + + const auto totalSamples{ static_cast((static_cast(duration.count()) * _pcmParams.sampleRate) / 1'000) }; + + constexpr std::size_t halfWindow{ WindowSize / 2 }; + if (totalSamples < halfWindow) // Not enough samples to produce even the first frame. + return 0; + + return 1 + ((totalSamples - halfWindow) / _hopSize); + } + + // Spectral data for a single frame. + struct SpectralFrameView + { + std::span rawSamples; + std::span powerSpectrum; + }; + + // Decodes up to frameCount frames, invoking callback for each. May return fewer than + // frameCount at EOF. Returns 0 only if no frame at all could be decoded. + template + requires std::invocable + std::size_t decodeFrames(std::size_t frameCount, Callback&& callback) + { + if (frameCount == 0) + return 0; + + std::size_t decodedCount{}; + while (decodedCount < frameCount) + { + if (!readAtLeastSamples(std::max(WindowSize, _hopSize))) + break; + + const std::span rawSamples{ _samplesBuffer.data(), WindowSize }; + const std::span windowedFrame{ _windowedFrame.data(), WindowSize }; + _window.apply(rawSamples, windowedFrame); + + _fftPlan.apply(_windowedFrame, _fftOutput); + + // Reuse _windowedFrame for power spectrum (spectrumSize <= WindowSize). + const std::span powerBuffer{ _windowedFrame.data(), spectrumSize }; + std::transform(_fftOutput.cbegin(), _fftOutput.cend(), powerBuffer.begin(), + [this](const std::complex& bin) { + return (bin.real() * bin.real() + bin.imag() * bin.imag()) * _powerScale; + }); + + const SpectralFrameView frame{ .rawSamples = rawSamples, + .powerSpectrum = std::span{ _windowedFrame.data(), spectrumSize } }; + std::invoke(callback, frame); + + consumeSamples(_hopSize); + ++_currentFrameIndex; + ++decodedCount; + } + + return decodedCount; + } + + // Skips exactly the next frameCount frames without computing FFT or invoking callbacks. + // Returns frameCount on success, 0 on EOF. + std::size_t skipFrames(std::size_t frameCount) + { + if (frameCount == 0) + return 0; + + std::size_t skippedFrameCount{}; + while (skippedFrameCount < frameCount) + { + if (!readAtLeastSamples(std::max(WindowSize, _hopSize))) + break; + + consumeSamples(_hopSize); + ++_currentFrameIndex; + ++skippedFrameCount; + } + + return skippedFrameCount; + } + + [[nodiscard]] std::size_t currentFrameIndex() const noexcept { return _currentFrameIndex; } + + private: + static constexpr std::size_t bufferFrameCount{ 20 }; + + bool readAtLeastSamples(std::size_t sampleCount) + { + if (sampleCount <= _bufferedSampleCount) + return true; + + if (_samplesBuffer.size() < sampleCount) + _samplesBuffer.resize(sampleCount); + + while ((_bufferedSampleCount < sampleCount) && !_endOfStream) + { + std::span dest{ _samplesBuffer.data() + _bufferedSampleCount, _samplesBuffer.size() - _bufferedSampleCount }; + assert(!dest.empty()); + if (dest.empty()) + break; + + std::array outputBuffers{ IPcmDecoder::WritableBuffer{ std::as_writable_bytes(dest) } }; + const std::size_t samplesRead{ _decoder->readSamples(outputBuffers) }; + if (samplesRead == 0) + { + _endOfStream = true; + break; + } + + _bufferedSampleCount += samplesRead; + } + + return _bufferedSampleCount >= sampleCount; + } + + void consumeSamples(std::size_t samplesToDrop) + { + // TODO use a circular buffer and only compacts at the end of the buffer + assert(samplesToDrop <= _bufferedSampleCount); + + const auto remaining{ _bufferedSampleCount - samplesToDrop }; + if (remaining) + { + std::move(_samplesBuffer.begin() + samplesToDrop, + _samplesBuffer.begin() + _bufferedSampleCount, + _samplesBuffer.begin()); + } + + _bufferedSampleCount = remaining; + } + + const PcmParameters _pcmParams; + const std::size_t _hopSize; + const math::HannWindow _window; + const FloatType _powerScale; + const FFTPlan _fftPlan{}; + std::unique_ptr _decoder; + std::vector _samplesBuffer; + core::AlignedHeapArray _windowedFrame{ FFTPlan::getInputSize() }; + core::AlignedHeapArray, FFTPlan::minBufferAlignment> _fftOutput{ FFTPlan::getOutputSize() }; + std::size_t _bufferedSampleCount{}; + std::size_t _currentFrameIndex{}; + bool _endOfStream{}; + }; +} // namespace lms::audio diff --git a/src/libs/audio/include/audio/IMusicNNEmbeddingExtractor.hpp b/src/libs/audio/include/audio/IMusicNNEmbeddingExtractor.hpp new file mode 100644 index 00000000..819c19e6 --- /dev/null +++ b/src/libs/audio/include/audio/IMusicNNEmbeddingExtractor.hpp @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +#include "audio/MusicNNEmbeddings.hpp" + +namespace lms::audio +{ + class IMusicNNEmbeddingExtractor + { + public: + virtual ~IMusicNNEmbeddingExtractor() = default; + + struct ExtractionResult + { + TrackMusicNNEmbeddings embeddings{}; + std::size_t patchCount{}; + }; + + [[nodiscard]] virtual ExtractionResult extract(const std::filesystem::path& audioFile) const = 0; + }; + + bool canExtractMusicNNEmbeddings(); + std::unique_ptr createMusicNNEmbeddingExtractor(const std::filesystem::path& modelPath, std::size_t maxPatchCount); + std::string getMusicNNModelIdentifier(const std::filesystem::path& modelPath); +} // namespace lms::audio diff --git a/src/libs/audio/include/audio/IPcmDecoder.hpp b/src/libs/audio/include/audio/IPcmDecoder.hpp index 59d75467..2afa03ee 100644 --- a/src/libs/audio/include/audio/IPcmDecoder.hpp +++ b/src/libs/audio/include/audio/IPcmDecoder.hpp @@ -43,8 +43,11 @@ namespace lms::audio // Each buffer must be sized to hold an integer number of samples according to the requested sample type. // For example, for Float32 planar output, each buffer size must be divisible by sizeof(float). // The decoder will use the buffer sizes to determine the maximum number of samples it can write. + // The decoder will not try to fill in the whole supplied buffer virtual std::size_t readSamples(std::span outputChannelBuffers) = 0; virtual bool finished() const = 0; + + virtual std::chrono::milliseconds getEstimatedDuration() const = 0; // initial offset is taken into account, 0 if unknown }; // Throw on error diff --git a/src/libs/audio/include/audio/MusicNNEmbeddings.hpp b/src/libs/audio/include/audio/MusicNNEmbeddings.hpp new file mode 100644 index 00000000..19834d3f --- /dev/null +++ b/src/libs/audio/include/audio/MusicNNEmbeddings.hpp @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +namespace lms::audio +{ + struct MusicNNEmbedding + { + static inline constexpr std::size_t size{ 200 }; + std::array values; + }; + + struct TrackMusicNNEmbeddings + { + MusicNNEmbedding mean; + }; + + // Buffer size must be at least sizeof(TrackMusicNNEmbeddings) + void trackMusicNNEmbeddingsToBlob(const TrackMusicNNEmbeddings& embeddings, std::span buffer); + void trackMusicNNEmbeddingsFromBlob(std::span buffer, TrackMusicNNEmbeddings& embeddings); +} // namespace lms::audio diff --git a/src/libs/audio/include/audio/PcmTypes.hpp b/src/libs/audio/include/audio/PcmTypes.hpp index 90338db3..2e3af127 100644 --- a/src/libs/audio/include/audio/PcmTypes.hpp +++ b/src/libs/audio/include/audio/PcmTypes.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include namespace lms::audio { @@ -41,4 +42,21 @@ namespace lms::audio std::endian byteOrder; bool planar; }; + + namespace helpers + { + template + std::size_t durationToSampleCount(std::chrono::duration duration, unsigned sampleRate) + { + return static_cast(duration.count() * sampleRate * Period::num / Period::den); + } + + template + Duration sampleCountToDuration(std::size_t sampleCount, unsigned sampleRate) + { + return std::chrono::duration_cast(std::chrono::duration(sampleCount) / sampleRate); + } + + std::size_t sampleCountToByteCount(std::size_t sampleCount, PcmSampleType sampleType, unsigned channelCount); + } // namespace helpers } // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/models/MSD_musicnn_embedding.onnx b/src/libs/audio/models/MSD_musicnn_embedding.onnx new file mode 100644 index 00000000..3bcc6dd6 Binary files /dev/null and b/src/libs/audio/models/MSD_musicnn_embedding.onnx differ diff --git a/src/libs/audio/test/CMakeLists.txt b/src/libs/audio/test/CMakeLists.txt new file mode 100644 index 00000000..7e7b1eee --- /dev/null +++ b/src/libs/audio/test/CMakeLists.txt @@ -0,0 +1,33 @@ +include(GoogleTest) + +add_executable(test-audio + MelFilterBank.cpp + MusicNNEmbeddings.cpp + PcmSpectralFrameDecoder.cpp + ) + +if (OnnxRuntime_FOUND) + target_sources(test-audio PRIVATE + MusicNNModel.cpp + ) +endif() + +target_include_directories(test-audio PRIVATE + ../impl + ) + +target_link_libraries(test-audio PRIVATE + lmsaudio + lmsmath + GTest::GTest + GTest::gtest_main + ) + +target_compile_options(test-audio PRIVATE + $<$>:-ffast-math> + ) + +if (NOT CMAKE_CROSSCOMPILING) + gtest_discover_tests(test-audio) +endif() + diff --git a/src/libs/audio/test/MelFilterBank.cpp b/src/libs/audio/test/MelFilterBank.cpp new file mode 100644 index 00000000..cb3d602e --- /dev/null +++ b/src/libs/audio/test/MelFilterBank.cpp @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include + +#include + +#include "audio/Exception.hpp" + +#include "features/MelFilterBank.hpp" + +namespace lms::audio::features::tests +{ + constexpr float epsilon{ 1e-5F }; + + constexpr std::size_t NFFT{ 2048 }; + constexpr std::size_t sampleRate{ 22050 }; + constexpr std::size_t filterCount{ 40 }; + + TEST(MelFilterBank, sizeCheck) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + EXPECT_EQ(bank.getFilterCount(), filterCount); + EXPECT_EQ(bank.getBinCount(), NFFT / 2 + 1); + } + + TEST(MelFilterBank, differentSampleRates) + { + for (const std::size_t sr : { std::size_t{ 8000 }, std::size_t{ 16000 }, std::size_t{ 44100 }, std::size_t{ 48000 } }) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sr, filterCount) }; + EXPECT_EQ(bank.getFilterCount(), filterCount) << "sr=" << sr; + } + } + + TEST(MelFilterBank, nonNegativeWeights) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const auto& filter = bank.getFilter(m); + for (float w : filter.weights) + EXPECT_GE(w, 0.F) << "Filter " << m << " has negative weight: " << w; + } + } + + TEST(MelFilterBank, peaksAreNonZero) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const auto& filter{ bank.getFilter(m) }; + const float maxVal{ *std::max_element(filter.weights.begin(), filter.weights.end()) }; + + EXPECT_GT(maxVal, 0.F) << "Filter " << m << " has zero peak value"; + } + } + + TEST(MelFilterBank, filtersAreUnitSum) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const auto& filter{ bank.getFilter(m) }; + const float sum{ std::accumulate(filter.weights.begin(), filter.weights.end(), 0.F) }; + + EXPECT_NEAR(sum, 1.F, epsilon) << "Filter " << m << " sum = " << sum; + } + } + + TEST(MelFilterBank, everyBinCovered) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + const std::size_t binCount{ bank.getBinCount() }; + + std::vector covered(binCount, false); + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const auto& filter{ bank.getFilter(m) }; + + std::size_t bin{ filter.leftBinIndex }; + for (float w : filter.weights) + { + if (w > 0.F) + covered[bin] = true; + + ++bin; + } + } + + // Find actual covered range + auto first{ std::find(covered.begin(), covered.end(), true) }; + auto last{ std::find(covered.rbegin(), covered.rend(), true).base() }; + + ASSERT_NE(first, covered.end()); // sanity + + for (auto it = first; it != last; ++it) + { + EXPECT_TRUE(*it) << "A bin in the covered range is not covered by any filter"; + } + } + + TEST(MelFilterBank, overlapAtMostTwo) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + const std::size_t binCount{ bank.getBinCount() }; + + std::vector overlap(binCount, 0); + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const auto& filter{ bank.getFilter(m) }; + + std::size_t bin{ filter.leftBinIndex }; + for (float w : filter.weights) + { + if (w > 0.F) + overlap[bin]++; + ++bin; + } + } + + for (std::size_t k{}; k < binCount; ++k) + { + EXPECT_LE(overlap[k], 2) << "Bin " << k << " is covered by " << overlap[k] << " filters"; + } + } + + TEST(MelFilterBank, flatSpectrumEnergySanity) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + std::vector flatSpectrum(bank.getBinCount(), 1.F); + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const float energy{ bank.computeEnergy(m, flatSpectrum) }; + EXPECT_GT(energy, 0.F) << "Filter " << m << " has zero energy for flat spectrum"; + } + } + TEST(MelFilterBank, zeroFilterCount) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, 0) }; + + EXPECT_EQ(bank.getFilterCount(), 0U); + EXPECT_EQ(bank.getBinCount(), NFFT / 2 + 1); + } + + TEST(MelFilterBank, computeEnergyRejectsInvalidInputSize) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + std::vector invalidInput(bank.getBinCount() - 1, 1.F); + + EXPECT_THROW(bank.computeEnergy(0, invalidInput), Exception); + } + + TEST(MelFilterBank, zeroSpectrum) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + std::vector zeroSpectrum(bank.getBinCount(), 0.F); + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const float energy{ bank.computeEnergy(m, zeroSpectrum) }; + EXPECT_FLOAT_EQ(energy, 0.F) << "Filter " << m << " should have zero energy for zero spectrum"; + } + } + + TEST(MelFilterBank, largeSpectrumValues) + { + const MelFilterBank bank{ computeMelFilterBank(NFFT, sampleRate, filterCount) }; + // Weights sum to 1.0 per filter, so energy = input * 1.0, no overflow risk at max/2 + const float largeValue{ std::numeric_limits::max() / 2.F }; + std::vector largeSpectrum(bank.getBinCount(), largeValue); + + for (std::size_t m{}; m < bank.getFilterCount(); ++m) + { + const float energy{ bank.computeEnergy(m, largeSpectrum) }; + EXPECT_GT(energy, 0.F) << "Filter " << m << " energy is not positive for large spectrum"; + } + } +} // namespace lms::audio::features::tests \ No newline at end of file diff --git a/src/libs/audio/test/MusicNNEmbeddings.cpp b/src/libs/audio/test/MusicNNEmbeddings.cpp new file mode 100644 index 00000000..573a082b --- /dev/null +++ b/src/libs/audio/test/MusicNNEmbeddings.cpp @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include + +#include + +#include "audio/Exception.hpp" +#include "audio/MusicNNEmbeddings.hpp" + +namespace lms::audio::tests +{ + TEST(MusicNNEmbeddings, blobRoundTrip) + { + TrackMusicNNEmbeddings original{}; + + std::iota(original.mean.values.begin(), original.mean.values.end(), 0.F); + + std::vector blob(sizeof(TrackMusicNNEmbeddings)); + trackMusicNNEmbeddingsToBlob(original, blob); + + TrackMusicNNEmbeddings restored{}; + trackMusicNNEmbeddingsFromBlob(blob, restored); + + for (std::size_t i{}; i < MusicNNEmbedding::size; ++i) + EXPECT_FLOAT_EQ(restored.mean.values[i], original.mean.values[i]); + } + + TEST(MusicNNEmbeddings, blobSizeTooSmallThrows) + { + TrackMusicNNEmbeddings embeddings{}; + std::vector blob(sizeof(TrackMusicNNEmbeddings) - 1); + EXPECT_THROW(trackMusicNNEmbeddingsToBlob(embeddings, blob), Exception); + } + + TEST(MusicNNEmbeddings, blobFromSizeTooSmallThrows) + { + std::vector blob(sizeof(TrackMusicNNEmbeddings) - 1, std::byte{}); + TrackMusicNNEmbeddings embeddings{}; + EXPECT_THROW(trackMusicNNEmbeddingsFromBlob(blob, embeddings), Exception); + } +} // namespace lms::audio::tests diff --git a/src/libs/audio/test/MusicNNModel.cpp b/src/libs/audio/test/MusicNNModel.cpp new file mode 100644 index 00000000..ea2f5180 --- /dev/null +++ b/src/libs/audio/test/MusicNNModel.cpp @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include +#include + +#include + +#include "musicnn/MusicNNModel.hpp" + +namespace lms::audio::musicnn::tests +{ + namespace + { + std::filesystem::path getMusicNNModelPathFromEnv() + { + const char* p{ std::getenv("LMS_MUSICNN_MODEL") }; + return p ? std::filesystem::path{ p } : std::filesystem::path{}; + } + + std::array makeRandomPatch() + { + std::minstd_rand rng{ 42 }; + std::uniform_real_distribution dist{ 0.F, 1.F }; + + std::array patch{}; + + for (float& v : patch) + v = dist(rng); + + return patch; + } + } // namespace + + TEST(MusicNNModel, CanConstruct) + { + const std::filesystem::path path{ getMusicNNModelPathFromEnv() }; + + if (path.empty()) + GTEST_SKIP() << "LMS_MUSICNN_MODEL not set"; + + EXPECT_NO_THROW({ const MusicNNModel model{ path }; }); + } + + TEST(MusicNNModel, CanForward) + { + const std::filesystem::path path{ getMusicNNModelPathFromEnv() }; + + if (path.empty()) + GTEST_SKIP() << "LMS_MUSICNN_MODEL not set"; + + const MusicNNModel model{ path }; + const auto patch{ makeRandomPatch() }; + + EXPECT_NO_THROW({ [[maybe_unused]] const auto output{ model.forward(patch) }; }); + } + +} // namespace lms::audio::musicnn::tests \ No newline at end of file diff --git a/src/libs/audio/test/PcmSpectralFrameDecoder.cpp b/src/libs/audio/test/PcmSpectralFrameDecoder.cpp new file mode 100644 index 00000000..a9ca7625 --- /dev/null +++ b/src/libs/audio/test/PcmSpectralFrameDecoder.cpp @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include +#include +#include + +#include + +#include "audio/IPcmDecoder.hpp" +#include "audio/PcmTypes.hpp" + +#include "utils/PcmSpectralFrameDecoder.hpp" + +namespace lms::audio::tests +{ + namespace + { + // A mock IPcmDecoder that emits samples 0, 1, 2, 3, ... (as float) up to totalSamples, + class SequencePcmDecoder : public IPcmDecoder + { + public: + SequencePcmDecoder(std::size_t totalSampleCount) + : _totalSampleCount{ totalSampleCount } + { + } + + const PcmParameters& getParameters() const override { return _params; } + + std::size_t readSamples(std::span outputChannelBuffers) override + { + assert(outputChannelBuffers.size() == 1); // only planar + if (_finished) + return 0; + + auto& buf{ outputChannelBuffers[0] }; + if (buf.size() == 0) + return 0; + + assert(buf.size() % sizeof(float) == 0); + + // not always writing up to what is requested + std::uniform_int_distribution dist{ std::size_t{ 1 }, buf.size() / sizeof(float) }; + std::size_t sampleCountToWrite{ dist(_randomEngine) }; + if (_currentSampleIndex + sampleCountToWrite > _totalSampleCount) + { + sampleCountToWrite = _totalSampleCount - _currentSampleIndex; + _finished = true; + } + + float* dest{ reinterpret_cast(buf.data()) }; + for (std::size_t i{}; i < sampleCountToWrite; ++i) + *(dest++) = static_cast(_currentSampleIndex++); + + return sampleCountToWrite; + } + + bool finished() const override { return _finished; } + + std::chrono::milliseconds getEstimatedDuration() const override + { + return std::chrono::duration_cast(std::chrono::duration{ static_cast(_totalSampleCount) / static_cast(_params.sampleRate) }); + } + + private: + const PcmParameters _params{ + .channelCount = 1, + .sampleRate = 16000, + .sampleType = PcmSampleType::Float32, + .byteOrder = std::endian::native, + .planar = false, + }; + + std::minstd_rand _randomEngine{ 42 }; // fixed seed for reproducibility + std::size_t _totalSampleCount{}; + std::size_t _currentSampleIndex{}; + bool _finished{}; + }; + + template + void expectSpanEq(std::span actual, const std::array& expected) + { + for (std::size_t i{}; i < N; ++i) + EXPECT_FLOAT_EQ(actual[i], expected[i]) << "index=" << i; + } + + constexpr std::size_t WindowSize{ 8 }; + constexpr std::size_t HopSize{ 4 }; + using FrameDecoder = PcmSpectralFrameDecoder; + } // namespace + + TEST(SequencePcmDecoder, basic) + { + constexpr std::size_t decoderTotalSampleCount{ 32 }; + SequencePcmDecoder decoder{ decoderTotalSampleCount }; + + std::size_t totalSampleReadCount{}; + while (true) + { + std::array buffer{}; + std::array outputBuffers{ IPcmDecoder::WritableBuffer{ std::as_writable_bytes(std::span{ buffer }) } }; + const std::size_t sampleReadCount{ decoder.readSamples(outputBuffers) }; + if (sampleReadCount == 0) + break; + + for (std::size_t i{}; i < sampleReadCount; ++i) + EXPECT_FLOAT_EQ(buffer[i], totalSampleReadCount + i); + + totalSampleReadCount += sampleReadCount; + } + + EXPECT_EQ(totalSampleReadCount, decoderTotalSampleCount); + } + + TEST(PcmSpectralFrameDecoder, firstFrameIsCenteredOnSample0) + { + FrameDecoder frameDecoder{ std::make_unique(32), HopSize }; + using Frame = std::array; + std::vector frames; + + EXPECT_EQ(frameDecoder.currentFrameIndex(), 0); + const std::size_t decoded{ frameDecoder.decodeFrames(1, + [&](const FrameDecoder::SpectralFrameView& frame) { + auto& newFrame{ frames.emplace_back() }; + std::copy(std::cbegin(frame.rawSamples), std::cend(frame.rawSamples), std::begin(newFrame)); + }) }; + + ASSERT_EQ(decoded, 1); + ASSERT_EQ(frames.size(), 1); + EXPECT_EQ(frameDecoder.currentFrameIndex(), 1); + expectSpanEq(frames[0], { 0.F, 0.F, 0.F, 0.F, 0.F, 1.F, 2.F, 3.F }); + } + + TEST(PcmSpectralFrameDecoder, framesAdvanceByHopSize) + { + FrameDecoder frameDecoder{ std::make_unique(16), HopSize }; + using Frame = std::array; + std::vector frames; + + const std::size_t decoded{ frameDecoder.decodeFrames(2, + [&](const FrameDecoder::SpectralFrameView& frame) { + auto& newFrame{ frames.emplace_back() }; + std::copy(std::cbegin(frame.rawSamples), std::cend(frame.rawSamples), std::begin(newFrame)); + }) }; + + ASSERT_EQ(decoded, 2); + ASSERT_EQ(frames.size(), 2); + EXPECT_EQ(frameDecoder.currentFrameIndex(), 2); + + expectSpanEq( + frames[0], + { 0.F, 0.F, 0.F, 0.F, 0.F, 1.F, 2.F, 3.F }); + + expectSpanEq( + frames[1], + { 0.F, 1.F, 2.F, 3.F, 4.F, 5.F, 6.F, 7.F }); + } + + TEST(PcmSpectralFrameDecoder, skipFramesAdvancesState) + { + FrameDecoder frameDecoder{ std::make_unique(32), HopSize }; + using Frame = std::array; + std::vector frames; + + EXPECT_EQ(frameDecoder.currentFrameIndex(), 0); + ASSERT_EQ(frameDecoder.skipFrames(2), 2); + EXPECT_EQ(frameDecoder.currentFrameIndex(), 2); + + Frame lastFrame{}; + + ASSERT_EQ(frameDecoder.decodeFrames(1, + [&](const FrameDecoder::SpectralFrameView& frame) { + std::copy(std::cbegin(frame.rawSamples), std::cend(frame.rawSamples), std::begin(lastFrame)); + }), + 1); + + expectSpanEq( + lastFrame, + { 4.F, 5.F, 6.F, 7.F, 8.F, 9.F, 10.F, 11.F }); + + EXPECT_EQ(frameDecoder.currentFrameIndex(), 3); + } +} // namespace lms::audio::tests diff --git a/src/libs/core/CMakeLists.txt b/src/libs/core/CMakeLists.txt index 6daf6301..5049c270 100644 --- a/src/libs/core/CMakeLists.txt +++ b/src/libs/core/CMakeLists.txt @@ -1,5 +1,5 @@ -pkg_check_modules(Config++ REQUIRED IMPORTED_TARGET libconfig++) pkg_check_modules(Archive REQUIRED IMPORTED_TARGET libarchive) +pkg_check_modules(Config++ REQUIRED IMPORTED_TARGET libconfig++) pkg_check_modules(XXHASH REQUIRED IMPORTED_TARGET libxxhash) set(LMS_VERSION ${PROJECT_VERSION}) diff --git a/src/libs/core/bench/CMakeLists.txt b/src/libs/core/bench/CMakeLists.txt index 9b2a3aa6..4ab4c319 100644 --- a/src/libs/core/bench/CMakeLists.txt +++ b/src/libs/core/bench/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(bench-core + Core.cpp TraceLoggerBench.cpp ) diff --git a/src/libs/services/scanner/test/Scanner.cpp b/src/libs/core/bench/Core.cpp similarity index 79% rename from src/libs/services/scanner/test/Scanner.cpp rename to src/libs/core/bench/Core.cpp index fa26648b..8915efc9 100644 --- a/src/libs/services/scanner/test/Scanner.cpp +++ b/src/libs/core/bench/Core.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * @@ -17,10 +17,6 @@ * along with LMS. If not, see . */ -#include +#include -int main(int argc, char** argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} +BENCHMARK_MAIN(); \ No newline at end of file diff --git a/src/libs/core/bench/TraceLoggerBench.cpp b/src/libs/core/bench/TraceLoggerBench.cpp index 06430750..c980ec1c 100644 --- a/src/libs/core/bench/TraceLoggerBench.cpp +++ b/src/libs/core/bench/TraceLoggerBench.cpp @@ -24,7 +24,7 @@ #include "core/ILogger.hpp" #include "core/ITraceLogger.hpp" -namespace lms::core +namespace lms::core::benchs { // The trace logger is meant to built/destroyed once const Service logger{ logging::createLogger() }; @@ -73,7 +73,4 @@ namespace lms::core BENCHMARK(BM_TraceLogger_Overview_withArg)->Threads(1)->Threads(std::thread::hardware_concurrency()); BENCHMARK(BM_TraceLogger_Detailed)->Threads(1)->Threads(std::thread::hardware_concurrency()); BENCHMARK(BM_TraceLogger_Detailed_withArg)->Threads(1)->Threads(std::thread::hardware_concurrency()); - -} // namespace lms::core - -BENCHMARK_MAIN(); \ No newline at end of file +} // namespace lms::core::benchs \ No newline at end of file diff --git a/src/libs/core/impl/XxHash3.cpp b/src/libs/core/impl/XxHash3.cpp index 07215ae4..f65df62f 100644 --- a/src/libs/core/impl/XxHash3.cpp +++ b/src/libs/core/impl/XxHash3.cpp @@ -18,14 +18,38 @@ */ #include "core/XxHash3.hpp" +#include "core/Exception.hpp" #define XXH_INLINE_ALL #include namespace lms::core { - std::uint64_t xxHash3_64(std::span buf) + std::uint64_t XxHash3_64::hash(std::span buf) { return XXH3_64bits(buf.data(), buf.size()); } + + XxHash3_64::XxHash3_64() + : _state{ XXH3_createState() } + { + if (!_state) + throw LmsException{ "XXH3_createState failed: out of memory" }; + XXH3_64bits_reset(static_cast(_state)); + } + + XxHash3_64::~XxHash3_64() + { + XXH3_freeState(static_cast(_state)); + } + + void XxHash3_64::update(std::span buf) + { + XXH3_64bits_update(static_cast(_state), buf.data(), buf.size()); + } + + std::uint64_t XxHash3_64::digest() const + { + return XXH3_64bits_digest(static_cast(_state)); + } } // namespace lms::core \ No newline at end of file diff --git a/src/libs/core/include/core/AlignedHeapArray.hpp b/src/libs/core/include/core/AlignedHeapArray.hpp new file mode 100644 index 00000000..3845977d --- /dev/null +++ b/src/libs/core/include/core/AlignedHeapArray.hpp @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +#include "core/Exception.hpp" + +namespace lms::core +{ + template + class AlignedHeapArray + { + public: + using iterator = T*; + using const_iterator = const T*; + + explicit AlignedHeapArray(std::size_t count) + : _count{ count } + , _values{ static_cast(std::aligned_alloc(Alignment, getStorageSize(count))) } + { + if (!_values) + throw LmsException{ "Allocation failed" }; + } + + ~AlignedHeapArray() + { + std::free(_values); + } + + AlignedHeapArray(const AlignedHeapArray&) = delete; + AlignedHeapArray& operator=(const AlignedHeapArray&) = delete; + + std::size_t size() const { return _count; } + T* data() const { return _values; } + T& operator[](std::size_t index) const { return _values[index]; } + + iterator begin() const { return _values; } + iterator end() const { return _values + _count; } + + const_iterator cbegin() const { return _values; } + const_iterator cend() const { return _values + _count; } + + private: + static std::size_t getStorageSize(std::size_t count) + { + const std::size_t bytes{ count * sizeof(T) }; + const std::size_t alignedBytes{ ((bytes + Alignment - 1) / Alignment) * Alignment }; + return alignedBytes; + } + + const std::size_t _count; + T* _values; + }; +} // namespace lms::core \ No newline at end of file diff --git a/src/libs/core/include/core/Crc32Calculator.hpp b/src/libs/core/include/core/Crc32Calculator.hpp index 604910cf..7b10bdbd 100644 --- a/src/libs/core/include/core/Crc32Calculator.hpp +++ b/src/libs/core/include/core/Crc32Calculator.hpp @@ -19,6 +19,8 @@ #pragma once +#include + #include // for boost::crc_32_type namespace lms::core diff --git a/src/libs/core/include/core/Random.hpp b/src/libs/core/include/core/Random.hpp index 898a49bc..5fd4515d 100644 --- a/src/libs/core/include/core/Random.hpp +++ b/src/libs/core/include/core/Random.hpp @@ -21,6 +21,7 @@ #include #include +#include namespace lms::core::random { @@ -43,12 +44,36 @@ namespace lms::core::random return dist(getRandGenerator()); } + template + requires std::is_floating_point_v + void fillContainer(RandomEngine& randomEngine, Container& container, typename Container::value_type min, typename Container::value_type max) + { + std::uniform_real_distribution distrib{ min, max }; + for (auto& v : container) + v = distrib(randomEngine); + } + + template + requires std::is_integral_v + void fillContainer(RandomEngine& randomEngine, Container& container, typename Container::value_type min, typename Container::value_type max) + { + std::uniform_int_distribution distrib{ min, max }; + for (auto& v : container) + v = distrib(randomEngine); + } + template void shuffleContainer(Container& container) { std::shuffle(std::begin(container), std::end(container), getRandGenerator()); } + template + void shuffleContainer(RandomEngine& randomEngine, Container& container) + { + std::shuffle(std::begin(container), std::end(container), randomEngine); + } + template typename Container::const_iterator pickRandom(const Container& container) { diff --git a/src/libs/core/include/core/Utils.hpp b/src/libs/core/include/core/Utils.hpp index 8340c227..f96e70ba 100644 --- a/src/libs/core/include/core/Utils.hpp +++ b/src/libs/core/include/core/Utils.hpp @@ -19,18 +19,8 @@ #pragma once -#include -#include - namespace lms::core::utils { - template - void push_back_if_not_present(Container& container, const T& val) - { - if (std::find(std::cbegin(container), std::cend(container), val) == std::cend(container)) - container.push_back(val); - } - template struct overloads : Ts... { diff --git a/src/libs/core/include/core/XxHash3.hpp b/src/libs/core/include/core/XxHash3.hpp index b960936f..bd7a8306 100644 --- a/src/libs/core/include/core/XxHash3.hpp +++ b/src/libs/core/include/core/XxHash3.hpp @@ -25,5 +25,20 @@ namespace lms::core { - std::uint64_t xxHash3_64(std::span buf); + class XxHash3_64 + { + public: + static std::uint64_t hash(std::span buf); + + XxHash3_64(); + ~XxHash3_64(); + XxHash3_64(const XxHash3_64&) = delete; + XxHash3_64& operator=(const XxHash3_64&) = delete; + + void update(std::span buf); + std::uint64_t digest() const; + + private: + void* _state{}; + }; } // namespace lms::core \ No newline at end of file diff --git a/src/libs/core/test/CMakeLists.txt b/src/libs/core/test/CMakeLists.txt index 82ee61a3..074abeb0 100644 --- a/src/libs/core/test/CMakeLists.txt +++ b/src/libs/core/test/CMakeLists.txt @@ -10,15 +10,16 @@ add_executable(test-core Service.cpp String.cpp TraceLogger.cpp - Utils.cpp UUID.cpp XxHash3.cpp ) target_link_libraries(test-core PRIVATE lmscore + lmsmath Threads::Threads GTest::GTest + GTest::gtest_main ) if (NOT CMAKE_CROSSCOMPILING) diff --git a/src/libs/core/test/XxHash3.cpp b/src/libs/core/test/XxHash3.cpp index 6950fdef..890ef482 100644 --- a/src/libs/core/test/XxHash3.cpp +++ b/src/libs/core/test/XxHash3.cpp @@ -31,7 +31,27 @@ namespace lms::core for (std::size_t i{}; i < buffer.size(); ++i) buffer[i] = static_cast(i); - const std::uint64_t hash{ xxHash3_64(buffer) }; + const std::uint64_t hash{ XxHash3_64::hash(buffer) }; EXPECT_EQ(hash, 12137474952470826274ULL); } + + TEST(Xxhash3_64, streamingMatchesOneShot) + { + std::vector buffer; + buffer.resize(1024); + + for (std::size_t i{}; i < buffer.size(); ++i) + buffer[i] = static_cast(i); + + const std::uint64_t expected{ XxHash3_64::hash(buffer) }; + + // Feed the same data in three unequal chunks to exercise the streaming path + constexpr std::size_t chunk1{ 100 }; + constexpr std::size_t chunk2{ 400 }; + XxHash3_64 hasher; + hasher.update(std::span{ buffer }.subspan(0, chunk1)); + hasher.update(std::span{ buffer }.subspan(chunk1, chunk2)); + hasher.update(std::span{ buffer }.subspan(chunk1 + chunk2)); + EXPECT_EQ(hasher.digest(), expected); + } } // namespace lms::core \ No newline at end of file diff --git a/src/libs/database/CMakeLists.txt b/src/libs/database/CMakeLists.txt index 8c0055c7..b59f17d1 100644 --- a/src/libs/database/CMakeLists.txt +++ b/src/libs/database/CMakeLists.txt @@ -28,7 +28,7 @@ add_library(lmsdatabase STATIC impl/objects/TrackBookmark.cpp impl/objects/TrackEmbeddedImage.cpp impl/objects/TrackEmbeddedImageLink.cpp - impl/objects/TrackFeatures.cpp + impl/objects/TrackMusicNNEmbeddings.cpp impl/objects/TrackList.cpp impl/objects/TrackLyrics.cpp impl/objects/Types.cpp @@ -38,7 +38,7 @@ add_library(lmsdatabase STATIC impl/IdType.cpp impl/Migration.cpp impl/Object.cpp - impl/QueryPlanRecorder.cpp + impl/profiling/QueryProfiler.cpp impl/Session.cpp impl/SqlQuery.cpp impl/Transaction.cpp @@ -59,6 +59,7 @@ target_include_directories(lmsdatabase PRIVATE ) target_link_libraries(lmsdatabase PRIVATE + lmsmath Wt::DboSqlite3 ) diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index e9fcf2c2..9039ba1c 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -35,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 103 }; + static constexpr Version LMS_DATABASE_VERSION{ 104 }; } VersionInfo::VersionInfo() @@ -1706,6 +1706,23 @@ FROM track)"); utils::executeCommand(*session.getDboSession(), "UPDATE scan_settings SET audio_scan_version = audio_scan_version + 1"); } + void migrateFromV103(Session& session) + { + // Drop previous track_audio_features with a brand new table dedicated to embeddings + utils::executeCommand(*session.getDboSession(), R"(DROP TABLE track_features)"); + + utils::executeCommand(*session.getDboSession(), R"(CREATE TABLE IF NOT EXISTS "track_musicnn_embeddings" ( + "id" integer primary key autoincrement, + "version" integer not null, + "data" blob not null, + "track_id" bigint, + constraint "fk_track_musicnn_embeddings_track" foreign key ("track_id") references "track" ("id") on delete cascade deferrable initially deferred + ))"); + + utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings RENAME COLUMN similarity_engine_type TO recommendation_engine_type"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE scan_settings ADD COLUMN musicnn_model_identifier TEXT NOT NULL DEFAULT ''"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -1785,6 +1802,7 @@ FROM track)"); { 100, migrateFromV100 }, { 101, migrateFromV101 }, { 102, migrateFromV102 }, + { 103, migrateFromV103 }, }; bool migrationPerformed{}; diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index 2f1a93ca..f1c0371f 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -52,9 +52,9 @@ #include "database/objects/TrackBookmark.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" -#include "database/objects/TrackFeatures.hpp" #include "database/objects/TrackList.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/TrackMusicNNEmbeddings.hpp" #include "database/objects/UIState.hpp" #include "database/objects/User.hpp" @@ -106,7 +106,7 @@ namespace lms::db _session.mapClass("track_artist_link"); _session.mapClass("track_embedded_image"); _session.mapClass("track_embedded_image_link"); - _session.mapClass("track_features"); + _session.mapClass("track_musicnn_embeddings"); _session.mapClass("tracklist"); _session.mapClass("tracklist_entry"); _session.mapClass("track_lyrics"); @@ -302,7 +302,7 @@ namespace lms::db utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_artist_idx ON track_artist_link(track_id, artist_id)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_artist_link_track_type_idx ON track_artist_link(track_id, type)"); - utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_features_track_idx ON track_features(track_id)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_musicnn_embeddings_track_idx ON track_musicnn_embeddings(track_id)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_id_idx ON track_lyrics(id)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS track_lyrics_absolute_file_path_idx ON track_lyrics(absolute_file_path)"); diff --git a/src/libs/database/impl/Utils.hpp b/src/libs/database/impl/Utils.hpp index bd8da398..3d13303c 100644 --- a/src/libs/database/impl/Utils.hpp +++ b/src/libs/database/impl/Utils.hpp @@ -29,10 +29,9 @@ #include #include "core/ITraceLogger.hpp" -#include "core/Service.hpp" #include "database/Types.hpp" -#include "QueryPlanRecorder.hpp" +#include "profiling/ScopedQueryProfiler.hpp" namespace lms::db::utils { @@ -42,16 +41,6 @@ namespace lms::db::utils Wt::WDateTime normalizeDateTime(const Wt::WDateTime& dateTime); - namespace detail - { - template - void recordQueryPlanIfNeeded(const Query& query) - { - if (IQueryPlanRecorder * recorder{ core::Service::get() }) - static_cast(recorder)->recordQueryPlanIfNeeded(query.session(), query.asString()); - } - } // namespace detail - template void applyRange(Query& query, std::optional range) { @@ -100,20 +89,22 @@ namespace lms::db::utils template void forEachQueryResult(const Query& query, UnaryFunc&& func) { - detail::recordQueryPlanIfNeeded(query); - LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "ForEachQueryResult", "Query", query.asString()); - forEachResult(query.resultList(), std::forward(func)); + ScopedQueryProfiler queryProfiler{ query }; + forEachResult(query.resultList(), [&](const auto& result) { + queryProfiler.suspend(); + func(result); + queryProfiler.resume(); + }); } template std::vector fetchQueryResults(const Query& query) { - detail::recordQueryPlanIfNeeded(query); - LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQueryResults", "Query", query.asString()); + ScopedQueryProfiler queryProfiler{ query }; auto collection{ query.resultList() }; return std::vector(collection.begin(), collection.end()); } @@ -121,10 +112,9 @@ namespace lms::db::utils template std::vector::type> fetchQueryResults(const Query& query) { - detail::recordQueryPlanIfNeeded(query); - LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQueryResults", "Query", query.asString()); + ScopedQueryProfiler queryProfiler{ query }; auto collection{ query.resultList() }; return std::vector::type>(collection.begin(), collection.end()); } @@ -132,9 +122,8 @@ namespace lms::db::utils template auto fetchQuerySingleResult(const Query& query) { - detail::recordQueryPlanIfNeeded(query); - LMS_SCOPED_TRACE_DETAILED_WITH_ARG("Database", "FetchQuerySingleResult", "Query", query.asString()); + ScopedQueryProfiler queryProfiler{ query }; return query.resultValue(); } @@ -184,6 +173,7 @@ namespace lms::db::utils moreResults = false; std::size_t count{}; + ScopedQueryProfiler queryProfiler{ query }; const auto collection{ query.resultList() }; auto it{ fetchFirstResult(collection) }; while (it != collection.end()) @@ -194,7 +184,9 @@ namespace lms::db::utils break; } + queryProfiler.suspend(); func(*it); + queryProfiler.resume(); fetchNextResult(it); } } diff --git a/src/libs/database/impl/objects/Artist.cpp b/src/libs/database/impl/objects/Artist.cpp index d974d43f..781d17e0 100644 --- a/src/libs/database/impl/objects/Artist.cpp +++ b/src/libs/database/impl/objects/Artist.cpp @@ -403,48 +403,6 @@ AND NOT EXISTS ( return _preferredArtwork.id(); } - RangeResults Artist::findSimilarArtistIds(core::EnumSet artistLinkTypes, std::optional range) const - { - assert(session()); - - std::ostringstream oss; - oss << "SELECT a.id FROM artist a" - " INNER JOIN track_artist_link t_a_l ON t_a_l.artist_id = a.id" - " INNER JOIN track t ON t.id = t_a_l.track_id" - " INNER JOIN track_cluster t_c ON t_c.track_id = t.id" - " WHERE " - " t_c.cluster_id IN (SELECT DISTINCT c.id from cluster c" - " INNER JOIN track t ON c.id = t_c.cluster_id" - " INNER JOIN track_cluster t_c ON t_c.track_id = t.id" - " INNER JOIN artist a ON a.id = t_a_l.artist_id" - " INNER JOIN track_artist_link t_a_l ON t_a_l.track_id = t.id" - " WHERE a.id = ?)" - " AND a.id <> ?"; - - if (!artistLinkTypes.empty()) - { - oss << " AND t_a_l.type IN ("; - - bool first{ true }; - for (TrackArtistLinkType type : artistLinkTypes) - { - (void)type; - if (!first) - oss << ", "; - oss << "?"; - first = false; - } - oss << ")"; - } - - auto query{ session()->query(oss.str()).bind(getId()).bind(getId()).groupBy("a.id").orderBy("COUNT(*) DESC, RANDOM()") }; - - for (const TrackArtistLinkType type : artistLinkTypes) - query.bind(type); - - return utils::execRangeQuery(query, range); - } - std::vector> Artist::getClusterGroups(std::span clusterTypeIds, std::size_t size) const { assert(session()); diff --git a/src/libs/database/impl/objects/Release.cpp b/src/libs/database/impl/objects/Release.cpp index c9d10dd5..1747fe75 100644 --- a/src/libs/database/impl/objects/Release.cpp +++ b/src/libs/database/impl/objects/Release.cpp @@ -749,33 +749,6 @@ namespace lms::db return utils::fetchQueryResults(query); } - std::vector Release::getSimilarReleases(std::optional offset, std::optional count) const - { - assert(session()); - - // Select the similar releases using the 5 most used clusters of the release - auto query{ session()->query>( - "SELECT r FROM release r" - " INNER JOIN track t ON t.release_id = r.id" - " INNER JOIN track_cluster t_c ON t_c.track_id = t.id" - " WHERE " - " t_c.cluster_id IN " - "(SELECT DISTINCT c.id FROM cluster c" - " INNER JOIN track t ON c.id = t_c.cluster_id" - " INNER JOIN track_cluster t_c ON t_c.track_id = t.id" - " INNER JOIN release r ON r.id = t.release_id" - " WHERE r.id = ?)" - " AND r.id <> ?") - .bind(getId()) - .bind(getId()) - .groupBy("r.id") - .orderBy("COUNT(*) DESC, RANDOM()") - .limit(count ? static_cast(*count) : -1) - .offset(offset ? static_cast(*offset) : -1) }; - - return utils::fetchQueryResults(query); - } - ObjectPtr Release::getPreferredArtwork() const { return ObjectPtr{ _preferredArtwork }; diff --git a/src/libs/database/impl/objects/Track.cpp b/src/libs/database/impl/objects/Track.cpp index d21794db..00aa93d5 100644 --- a/src/libs/database/impl/objects/Track.cpp +++ b/src/libs/database/impl/objects/Track.cpp @@ -36,7 +36,6 @@ #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" -#include "database/objects/TrackFeatures.hpp" #include "database/objects/TrackLyrics.hpp" #include "database/objects/User.hpp" @@ -191,6 +190,20 @@ namespace lms::db if (params.fileSize.has_value()) query.where("t.file_size = ?").bind(static_cast(params.fileSize.value())); + if (params.hasMusicNNEmbeddings.has_value()) + { + if (*params.hasMusicNNEmbeddings) + query.where("EXISTS (SELECT t_m_e.track_id FROM track_musicnn_embeddings t_m_e WHERE t_m_e.track_id = t.id)"); + else + query.where("NOT EXISTS (SELECT t_m_e.track_id FROM track_musicnn_embeddings t_m_e WHERE t_m_e.track_id = t.id)"); + } + + if (params.lastTrackId.isValid()) + { + assert(params.sortMethod == TrackSortMethod::Id); + query.where("t.id > ?").bind(params.lastTrackId); + } + if (params.embeddedImageId.isValid()) { query.join("track_embedded_image_link t_e_i_l ON t_e_i_l.track_id = t.id"); @@ -322,7 +335,7 @@ namespace lms::db }); } - void Track::findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const std::function& func) + void Track::findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const TrackLocationVisitor& func) { session.checkReadTransaction(); @@ -334,6 +347,19 @@ namespace lms::db }); } + void Track::findAbsoluteFilePath(Session& session, const FindParameters& params, const TrackLocationVisitor& func) + { + session.checkReadTransaction(); + + std::string_view itemToSelect{ "t.id, t.absolute_file_path" }; + + auto query{ createQuery>(session, itemToSelect, params) }; + + utils::forEachQueryRangeResult(query, params.range, [&](const auto& res) { + func(std::get<0>(res), std::get<1>(res)); + }); + } + void Track::find(Session& session, const IdRange& idRange, const std::function& func) { assert(idRange.isValid()); @@ -385,15 +411,6 @@ namespace lms::db return utils::execRangeQuery(query, range); } - RangeResults Track::findIdsWithRecordingMBIDAndMissingFeatures(Session& session, std::optional range) - { - session.checkReadTransaction(); - - auto query{ session.getDboSession()->query("SELECT t.id FROM track t").where("LENGTH(t.recording_mbid) > 0").where("NOT EXISTS (SELECT * FROM track_features t_f WHERE t_f.track_id = t.id)") }; - - return utils::execRangeQuery(query, range); - } - void Track::updatePreferredArtwork(Session& session, TrackId trackId, ArtworkId artworkId) { session.checkWriteTransaction(); @@ -490,36 +507,11 @@ namespace lms::db utils::forEachQueryRangeResult(query, params.range, moreResults, func); } - RangeResults Track::findSimilarTrackIds(Session& session, const std::vector& tracks, std::optional range) + std::size_t Track::getCount(Session& session, const FindParameters& params) { - assert(!tracks.empty()); session.checkReadTransaction(); - std::ostringstream oss; - for (std::size_t i{}; i < tracks.size(); ++i) - { - if (!oss.str().empty()) - oss << ", "; - oss << "?"; - } - - auto query{ session.getDboSession()->query( - "SELECT t.id FROM track t" - " INNER JOIN track_cluster t_c ON t_c.track_id = t.id" - " AND t_c.cluster_id IN (SELECT DISTINCT c.id FROM cluster c INNER JOIN track_cluster t_c ON t_c.cluster_id = c.id WHERE t_c.track_id IN (" - + oss.str() + "))" - " AND t.id NOT IN (" - + oss.str() + ")") - .groupBy("t.id") - .orderBy("COUNT(*) DESC, RANDOM()") }; - - for (TrackId trackId : tracks) - query.bind(trackId); - - for (TrackId trackId : tracks) - query.bind(trackId); - - return utils::execRangeQuery(query, range); + return utils::fetchQuerySingleResult(createQuery(session, "COUNT(*)", params)); } void Track::setAbsoluteFilePath(const std::filesystem::path& filePath) diff --git a/src/libs/database/impl/objects/TrackFeatures.cpp b/src/libs/database/impl/objects/TrackFeatures.cpp deleted file mode 100644 index 46c9607a..00000000 --- a/src/libs/database/impl/objects/TrackFeatures.cpp +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (C) 2018 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include "database/objects/TrackFeatures.hpp" - -#include -#include -#include - -#include "core/ILogger.hpp" -#include "database/Session.hpp" -#include "database/objects/Directory.hpp" -#include "database/objects/Track.hpp" - -#include "Utils.hpp" -#include "traits/IdTypeTraits.hpp" - -DBO_INSTANTIATE_TEMPLATES(lms::db::TrackFeatures) - -namespace lms::db -{ - - TrackFeatures::TrackFeatures(ObjectPtr track, const std::string& jsonEncodedFeatures) - : _data{ jsonEncodedFeatures } - , _track{ getDboPtr(track) } - { - } - - TrackFeatures::pointer TrackFeatures::create(Session& session, ObjectPtr track, const std::string& jsonEncodedFeatures) - { - return session.getDboSession()->add(std::unique_ptr{ new TrackFeatures{ track, jsonEncodedFeatures } }); - } - - std::size_t TrackFeatures::getCount(Session& session) - { - session.checkReadTransaction(); - - return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM track_features")); - } - - TrackFeatures::pointer TrackFeatures::find(Session& session, TrackFeaturesId id) - { - session.checkReadTransaction(); - - return utils::fetchQuerySingleResult(session.getDboSession()->find().where("id = ?").bind(id)); - } - - TrackFeatures::pointer TrackFeatures::find(Session& session, TrackId trackId) - { - session.checkReadTransaction(); - - return utils::fetchQuerySingleResult(session.getDboSession()->find().where("track_id = ?").bind(trackId)); - } - - RangeResults TrackFeatures::find(Session& session, std::optional range) - { - session.checkReadTransaction(); - - auto query{ session.getDboSession()->query("SELECT id from track_features") }; - - return utils::execRangeQuery(query, range); - } - - FeatureValues TrackFeatures::getFeatureValues(const FeatureName& featureNode) const - { - FeatureValuesMap featuresValuesMap{ getFeatureValuesMap({ featureNode }) }; - return std::move(featuresValuesMap[featureNode]); - } - - FeatureValuesMap TrackFeatures::getFeatureValuesMap(const std::unordered_set& featureNames) const - { - FeatureValuesMap res; - - try - { - std::istringstream iss{ _data }; - boost::property_tree::ptree root; - - boost::property_tree::read_json(iss, root); - - for (const FeatureName& featureName : featureNames) - { - FeatureValues& featureValues{ res[featureName] }; - - auto node{ root.get_child(featureName) }; - - bool hasChildren = false; - for (const auto& child : node.get_child("")) - { - hasChildren = true; - featureValues.push_back(child.second.get_value()); - } - - if (!hasChildren) - featureValues.push_back(node.get_value()); - } - } - catch (boost::property_tree::ptree_error& error) - { - LMS_LOG(DB, ERROR, "Track " << _track.id() << ": ptree exception: " << error.what()); - res.clear(); - } - - return res; - } - -} // namespace lms::db diff --git a/src/libs/database/impl/objects/TrackMusicNNEmbeddings.cpp b/src/libs/database/impl/objects/TrackMusicNNEmbeddings.cpp new file mode 100644 index 00000000..4ea93deb --- /dev/null +++ b/src/libs/database/impl/objects/TrackMusicNNEmbeddings.cpp @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "database/objects/TrackMusicNNEmbeddings.hpp" + +#include + +#include "database/Session.hpp" +#include "database/objects/Track.hpp" + +#include "Utils.hpp" +#include "traits/IdTypeTraits.hpp" + +DBO_INSTANTIATE_TEMPLATES(lms::db::TrackMusicNNEmbeddings) + +namespace lms::db +{ + TrackMusicNNEmbeddings::TrackMusicNNEmbeddings(ObjectPtr track) + : _track{ getDboPtr(track) } + { + } + + TrackMusicNNEmbeddings::pointer TrackMusicNNEmbeddings::create(Session& session, ObjectPtr track) + { + return session.getDboSession()->add(std::unique_ptr{ new TrackMusicNNEmbeddings{ track } }); + } + + std::size_t TrackMusicNNEmbeddings::getCount(Session& session) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM track_musicnn_embeddings")); + } + + TrackMusicNNEmbeddings::pointer TrackMusicNNEmbeddings::find(Session& session, TrackMusicNNEmbeddingsId id) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("id = ?").bind(id)); + } + + TrackMusicNNEmbeddings::pointer TrackMusicNNEmbeddings::find(Session& session, TrackId trackId) + { + session.checkReadTransaction(); + + return utils::fetchQuerySingleResult(session.getDboSession()->find().where("track_id = ?").bind(trackId)); + } + + RangeResults TrackMusicNNEmbeddings::find(Session& session, std::optional range) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->query("SELECT id from track_musicnn_embeddings") }; + + return utils::execRangeQuery(query, range); + } + + void TrackMusicNNEmbeddings::find(Session& session, std::function func) + { + auto query{ session.getDboSession()->find() }; + + utils::forEachQueryResult(query, [&](const TrackMusicNNEmbeddings::pointer& embeddings) { + func(embeddings); + }); + } + + void TrackMusicNNEmbeddings::removeAll(Session& session) + { + session.checkWriteTransaction(); + utils::executeCommand(*session.getDboSession(), "DELETE FROM track_musicnn_embeddings"); + } + + std::span TrackMusicNNEmbeddings::getData() const + { + return std::span{ reinterpret_cast(_data.data()), _data.size() }; + } + + void TrackMusicNNEmbeddings::setData(std::span data) + { + const auto* start{ reinterpret_cast(data.data()) }; + _data.assign(start, start + data.size()); + } +} // namespace lms::db diff --git a/src/libs/database/impl/QueryPlanRecorder.cpp b/src/libs/database/impl/profiling/QueryProfiler.cpp similarity index 59% rename from src/libs/database/impl/QueryPlanRecorder.cpp rename to src/libs/database/impl/profiling/QueryProfiler.cpp index fef01d1c..b40d4440 100644 --- a/src/libs/database/impl/QueryPlanRecorder.cpp +++ b/src/libs/database/impl/profiling/QueryProfiler.cpp @@ -17,7 +17,7 @@ * along with LMS. If not, see . */ -#include "QueryPlanRecorder.hpp" +#include "profiling/QueryProfiler.hpp" #include #include @@ -29,35 +29,38 @@ namespace lms::db { - std::unique_ptr createQueryPlanRecorder() + std::unique_ptr createQueryProfiler() { - return std::make_unique(); + return std::make_unique(); } - QueryPlanRecorder::QueryPlanRecorder() + QueryProfiler::QueryProfiler() { - LMS_LOG(DB, INFO, "Recording database query plans"); + LMS_LOG(DB, INFO, "Recording database queries"); } - QueryPlanRecorder::~QueryPlanRecorder() = default; + QueryProfiler::~QueryProfiler() = default; - void QueryPlanRecorder::visitQueryPlans(const QueryPlanVisitor& visitor) const + void QueryProfiler::visitQueries(const QueryVisitor& visitor) const { const std::shared_lock lock{ _mutex }; - for (const auto& [query, plan] : _queryPlans) - visitor(query, plan); + for (const auto& [query, data] : _queries) + { + const QueryStats stats{ + .query = query, + .plan = data.plan, + .callCount = data.timeStats.getCount(), + .totalTime = std::chrono::microseconds{ static_cast(data.timeStats.getMean() * static_cast(data.timeStats.getCount())) }, + .meanTime = std::chrono::microseconds{ static_cast(data.timeStats.getMean()) }, + .stdDevTime = std::chrono::microseconds{ static_cast(data.timeStats.getSampleStdDev()) }, + }; + visitor(stats); + } } - void QueryPlanRecorder::recordQueryPlanIfNeeded(Wt::Dbo::Session& session, const std::string& query) + void QueryProfiler::recordQueryPlan(Wt::Dbo::Session& session, const std::string& query) { - { - const std::shared_lock lock{ _mutex }; - - if (_queryPlans.contains(query)) - return; - } - Wt::Dbo::Transaction transaction{ session }; Wt::Dbo::SqlConnection* connection{ transaction.connection() }; @@ -106,7 +109,23 @@ namespace lms::db { const std::unique_lock lock{ _mutex }; - _queryPlans.try_emplace(query, std::move(result)); + _queries[query].plan = std::move(result); } } + + void QueryProfiler::recordQueryExecution(Wt::Dbo::Session& session, const std::string& query, Clock::duration elapsed) + { + bool needQueryPlan{}; + const double elapsedUs{ std::chrono::duration_cast>(elapsed).count() }; + { + std::unique_lock lock{ _mutex }; + + auto& queryStats{ _queries[query] }; + queryStats.timeStats.add(elapsedUs); + needQueryPlan = queryStats.plan.empty(); + } + + if (needQueryPlan) + recordQueryPlan(session, query); + } } // namespace lms::db diff --git a/src/libs/database/impl/QueryPlanRecorder.hpp b/src/libs/database/impl/profiling/QueryProfiler.hpp similarity index 54% rename from src/libs/database/impl/QueryPlanRecorder.hpp rename to src/libs/database/impl/profiling/QueryProfiler.hpp index 6025b7a1..2155c0e3 100644 --- a/src/libs/database/impl/QueryPlanRecorder.hpp +++ b/src/libs/database/impl/profiling/QueryProfiler.hpp @@ -25,24 +25,33 @@ #include -#include "database/IQueryPlanRecorder.hpp" +#include "database/profiling/IQueryProfiler.hpp" +#include "math/StatsAccumulator.hpp" namespace lms::db { - class QueryPlanRecorder : public IQueryPlanRecorder + class QueryProfiler : public IQueryProfiler { public: - QueryPlanRecorder(); - ~QueryPlanRecorder() override; - QueryPlanRecorder(const QueryPlanRecorder&) = delete; - QueryPlanRecorder& operator=(const QueryPlanRecorder&) = delete; + QueryProfiler(); + ~QueryProfiler() override; + QueryProfiler(const QueryProfiler&) = delete; + QueryProfiler& operator=(const QueryProfiler&) = delete; - void visitQueryPlans(const QueryPlanVisitor& visitor) const override; + void visitQueries(const QueryVisitor& visitor) const override; - void recordQueryPlanIfNeeded(Wt::Dbo::Session& session, const std::string& query); + void recordQueryExecution(Wt::Dbo::Session& session, const std::string& query, Clock::duration elapsed); private: + void recordQueryPlan(Wt::Dbo::Session& session, const std::string& query); + + struct QueryData + { + std::string plan; + math::StatsAccumulator timeStats; // in Us + }; + mutable std::shared_mutex _mutex; - std::map _queryPlans; + std::map _queries; }; } // namespace lms::db diff --git a/src/libs/database/impl/profiling/ScopedQueryProfiler.hpp b/src/libs/database/impl/profiling/ScopedQueryProfiler.hpp new file mode 100644 index 00000000..db3a0bb5 --- /dev/null +++ b/src/libs/database/impl/profiling/ScopedQueryProfiler.hpp @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2025 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +#include "core/Service.hpp" +#include "database/profiling/IQueryProfiler.hpp" + +#include "profiling/QueryProfiler.hpp" + +namespace lms::db::utils +{ + template + class ScopedQueryProfiler + { + public: + explicit ScopedQueryProfiler(const Query& query) + : _recorder{ static_cast(core::Service::get()) } + { + if (_recorder) + { + _query = &query; + _start = IQueryProfiler::Clock::now(); + } + } + + ~ScopedQueryProfiler() + { + if (_recorder) + { + if (_active) + _elapsed += IQueryProfiler::Clock::now() - _start; + _recorder->recordQueryExecution(_query->session(), _query->asString(), _elapsed); + } + } + + ScopedQueryProfiler(const ScopedQueryProfiler&) = delete; + ScopedQueryProfiler& operator=(const ScopedQueryProfiler&) = delete; + + void suspend() + { + if (_recorder) + { + assert(_active); + _elapsed += IQueryProfiler::Clock::now() - _start; + _active = false; + } + } + + void resume() + { + if (_recorder) + { + assert(!_active); + _start = IQueryProfiler::Clock::now(); + _active = true; + } + } + + private: + QueryProfiler* _recorder{}; + const Query* _query{}; + IQueryProfiler::Clock::time_point _start; + IQueryProfiler::Clock::duration _elapsed{}; + bool _active{ true }; + }; +} // namespace lms::db::utils diff --git a/src/libs/database/include/database/objects/Artist.hpp b/src/libs/database/include/database/objects/Artist.hpp index b12ed211..1d3adbda 100644 --- a/src/libs/database/include/database/objects/Artist.hpp +++ b/src/libs/database/include/database/objects/Artist.hpp @@ -29,7 +29,6 @@ #include #include -#include "core/EnumSet.hpp" #include "core/UUID.hpp" #include "database/IdRange.hpp" @@ -39,7 +38,6 @@ #include "database/objects/ArtworkId.hpp" #include "database/objects/Filters.hpp" #include "database/objects/MediaLibraryId.hpp" -#include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" #include "database/objects/Types.hpp" #include "database/objects/UserId.hpp" @@ -148,9 +146,6 @@ namespace lms::db ObjectPtr getPreferredArtwork() const; ArtworkId getPreferredArtworkId() const; - // No artistLinkTypes means get them all - RangeResults findSimilarArtistIds(core::EnumSet artistLinkTypes = {}, std::optional range = std::nullopt) const; - // Get the cluster of the tracks made by this artist // Each clusters are grouped by cluster type, sorted by the number of occurence // size is the max number of cluster per cluster type diff --git a/src/libs/database/include/database/objects/Release.hpp b/src/libs/database/include/database/objects/Release.hpp index 30a4bd8c..53eec131 100644 --- a/src/libs/database/include/database/objects/Release.hpp +++ b/src/libs/database/include/database/objects/Release.hpp @@ -349,8 +349,6 @@ namespace lms::db void visitTrackArtists(TrackArtistLinkType type, std::function&)> visitor) const; std::vector getTrackArtistIds(TrackArtistLinkType type = TrackArtistLinkType::Artist) const; bool hasVariousArtists() const; - std::vector getSimilarReleases(std::optional offset = {}, std::optional count = {}) const; - template void persist(Action& a) { diff --git a/src/libs/database/include/database/objects/ScanSettings.hpp b/src/libs/database/include/database/objects/ScanSettings.hpp index 83f3e7ac..53593388 100644 --- a/src/libs/database/include/database/objects/ScanSettings.hpp +++ b/src/libs/database/include/database/objects/ScanSettings.hpp @@ -50,11 +50,11 @@ namespace lms::db }; // Do not modify values (just add) - enum class SimilarityEngineType + enum class RecommendationEngineType { Clusters = 0, - Features, - None, + None = 2, + AudioSimilarity = 3, }; ScanSettings() = default; @@ -65,10 +65,11 @@ namespace lms::db // Getters std::size_t getAudioScanVersion() const { return _audioScanVersion; } std::size_t getArtistInfoScanVersion() const { return _artistInfoScanVersion; } + std::string_view getMusicNNModelIdentifier() const { return _musicnnModelIdentifier; } Wt::WTime getUpdateStartTime() const { return _startTime; } UpdatePeriod getUpdatePeriod() const { return _updatePeriod; } std::vector getExtraTagsToScan() const; - SimilarityEngineType getSimilarityEngineType() const { return _similarityEngineType; } + RecommendationEngineType getRecommendationEngineType() const { return _recommendationEngineType; } std::vector getArtistTagDelimiters() const; std::vector getDefaultTagDelimiters() const; std::vector getArtistsToNotSplit() const; @@ -80,14 +81,14 @@ namespace lms::db void setUpdateStartTime(Wt::WTime t) { _startTime = t; } void setUpdatePeriod(UpdatePeriod p) { _updatePeriod = p; } void setExtraTagsToScan(std::span extraTags); - void setSimilarityEngineType(SimilarityEngineType type) { _similarityEngineType = type; } + void setRecommendationEngineType(RecommendationEngineType type) { _recommendationEngineType = type; } void setArtistTagDelimiters(std::span delimiters); void setArtistsToNotSplit(std::span artists); void setDefaultTagDelimiters(std::span delimiters); void setSkipSingleReleasePlayLists(bool value); void setAllowMBIDArtistMerge(bool value); void setArtistImageFallbackToReleaseField(bool value); - + void setMusicNNModelIdentifier(std::string_view identifier) { _musicnnModelIdentifier = identifier; } template void persist(Action& a) { @@ -96,7 +97,7 @@ namespace lms::db Wt::Dbo::field(a, _artistInfoScanVersion, "artist_info_scan_version"); Wt::Dbo::field(a, _startTime, "start_time"); Wt::Dbo::field(a, _updatePeriod, "update_period"); - Wt::Dbo::field(a, _similarityEngineType, "similarity_engine_type"); + Wt::Dbo::field(a, _recommendationEngineType, "recommendation_engine_type"); Wt::Dbo::field(a, _extraTagsToScan, "extra_tags_to_scan"); Wt::Dbo::field(a, _artistTagDelimiters, "artist_tag_delimiters"); Wt::Dbo::field(a, _artistsToNotSplit, "artists_to_not_split"); @@ -104,6 +105,7 @@ namespace lms::db Wt::Dbo::field(a, _skipSingleReleasePlayLists, "skip_single_release_playlists"); Wt::Dbo::field(a, _allowMBIDArtistMerge, "allow_mbid_artist_merge"); Wt::Dbo::field(a, _artistImageFallbackToReleaseField, "artist_image_fallback_to_release"); + Wt::Dbo::field(a, _musicnnModelIdentifier, "musicnn_model_identifier"); } private: @@ -119,7 +121,7 @@ namespace lms::db int _artistInfoScanVersion{}; Wt::WTime _startTime = Wt::WTime{ 0, 0, 0 }; UpdatePeriod _updatePeriod{ UpdatePeriod::Never }; - SimilarityEngineType _similarityEngineType{ SimilarityEngineType::Clusters }; + RecommendationEngineType _recommendationEngineType{ RecommendationEngineType::Clusters }; std::string _extraTagsToScan; std::string _artistTagDelimiters; std::string _artistsToNotSplit; @@ -127,5 +129,6 @@ namespace lms::db bool _skipSingleReleasePlayLists{}; bool _allowMBIDArtistMerge{}; bool _artistImageFallbackToReleaseField{}; + std::string _musicnnModelIdentifier; }; } // namespace lms::db diff --git a/src/libs/database/include/database/objects/Track.hpp b/src/libs/database/include/database/objects/Track.hpp index 6aad6854..cc06c373 100644 --- a/src/libs/database/include/database/objects/Track.hpp +++ b/src/libs/database/include/database/objects/Track.hpp @@ -97,6 +97,8 @@ namespace lms::db DirectoryId directory; // if set, tracks in this directory std::optional fileSize; // if set, tracks that match this file size TrackEmbeddedImageId embeddedImageId; // if set, tracks that have this embedded image + std::optional hasMusicNNEmbeddings; // If set, tracks that have (or not) MusicNN embeddings + TrackId lastTrackId; // If set, tracks that are after this one, must be used with sort by id FindParameters& setFilters(const Filters& _filters) { @@ -191,6 +193,16 @@ namespace lms::db embeddedImageId = _embeddedImageId; return *this; } + FindParameters& setHasMusicNNEmbeddings(std::optional _hasMusicNNEmbeddings) + { + hasMusicNNEmbeddings = _hasMusicNNEmbeddings; + return *this; + } + FindParameters& setLastTrackId(TrackId _lastTrackId) + { + lastTrackId = _lastTrackId; + return *this; + } }; Track() = default; @@ -203,19 +215,20 @@ namespace lms::db static void find(Session& session, TrackId& lastRetrievedId, std::size_t count, const std::function& func, MediaLibraryId library = {}); static void find(Session& session, const IdRange& idRange, const std::function& func); static IdRange findNextIdRange(Session& session, TrackId lastRetrievedId, std::size_t count); - static void findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const std::function& func); + + using TrackLocationVisitor = std::function; + static void findAbsoluteFilePath(Session& session, TrackId& lastRetrievedId, std::size_t count, const TrackLocationVisitor& func); + static void findAbsoluteFilePath(Session& session, const FindParameters& params, const TrackLocationVisitor& func); static bool exists(Session& session, TrackId id); static std::vector findByRecordingMBID(Session& session, const core::UUID& MBID); static std::vector findByMBID(Session& session, const core::UUID& MBID); - static RangeResults findSimilarTrackIds(Session& session, const std::vector& trackIds, std::optional range = std::nullopt); - - static RangeResults findIds(Session& session, const FindParameters& parameters); - static RangeResults find(Session& session, const FindParameters& parameters); - static void find(Session& session, const FindParameters& parameters, const std::function& func); - static void find(Session& session, const FindParameters& parameters, bool& moreResults, const std::function& func); + static RangeResults findIds(Session& session, const FindParameters& params); + static RangeResults find(Session& session, const FindParameters& params); + static void find(Session& session, const FindParameters& params, const std::function& func); + static void find(Session& session, const FindParameters& params, bool& moreResults, const std::function& func); + static std::size_t getCount(Session& session, const FindParameters& params); static RangeResults findIdsTrackMBIDDuplicates(Session& session, std::optional range = std::nullopt); - static RangeResults findIdsWithRecordingMBIDAndMissingFeatures(Session& session, std::optional range = std::nullopt); // Update utility functions static void updatePreferredArtwork(Session& session, TrackId trackId, ArtworkId artworkId); diff --git a/src/libs/database/include/database/objects/TrackFeatures.hpp b/src/libs/database/include/database/objects/TrackMusicNNEmbeddings.hpp similarity index 61% rename from src/libs/database/include/database/objects/TrackFeatures.hpp rename to src/libs/database/include/database/objects/TrackMusicNNEmbeddings.hpp index bea09c34..ea99bbcb 100644 --- a/src/libs/database/include/database/objects/TrackFeatures.hpp +++ b/src/libs/database/include/database/objects/TrackMusicNNEmbeddings.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * @@ -20,10 +20,7 @@ #pragma once #include -#include -#include -#include -#include +#include #include @@ -32,34 +29,33 @@ #include "database/Types.hpp" #include "database/objects/TrackId.hpp" -LMS_DECLARE_IDTYPE(TrackFeaturesId) +LMS_DECLARE_IDTYPE(TrackMusicNNEmbeddingsId) namespace lms::db { class Session; class Track; - using FeatureName = std::string; - using FeatureValues = std::vector; - using FeatureValuesMap = std::unordered_map; - - class TrackFeatures final : public Object + class TrackMusicNNEmbeddings final : public Object { public: - TrackFeatures() = default; + TrackMusicNNEmbeddings() = default; // Find utilities static std::size_t getCount(Session& session); - static pointer find(Session& session, TrackFeaturesId id); + static pointer find(Session& session, TrackMusicNNEmbeddingsId id); static pointer find(Session& session, TrackId trackId); - static RangeResults find(Session& session, std::optional range = std::nullopt); - - FeatureValues getFeatureValues(const FeatureName& feature) const; - FeatureValuesMap getFeatureValuesMap(const std::unordered_set& featureNames) const; + static RangeResults find(Session& session, std::optional range = std::nullopt); + static void find(Session& session, std::function func); + static void removeAll(Session& session); // Accessors + std::span getData() const; + TrackId getTrackId() const { return _track.id(); } Wt::Dbo::ptr getTrack() const { return _track; } + void setData(std::span data); + template void persist(Action& a) { @@ -69,11 +65,10 @@ namespace lms::db private: friend class Session; - TrackFeatures(ObjectPtr track, const std::string& jsonEncodedFeatures); - static pointer create(Session& session, ObjectPtr track, const std::string& jsonEncodedFeatures); + TrackMusicNNEmbeddings(ObjectPtr track); + static pointer create(Session& session, ObjectPtr track); - std::string _data; + std::vector _data; Wt::Dbo::ptr _track; }; - } // namespace lms::db diff --git a/src/libs/database/include/database/IQueryPlanRecorder.hpp b/src/libs/database/include/database/profiling/IQueryProfiler.hpp similarity index 60% rename from src/libs/database/include/database/IQueryPlanRecorder.hpp rename to src/libs/database/include/database/profiling/IQueryProfiler.hpp index 298634c8..4f9d18ab 100644 --- a/src/libs/database/include/database/IQueryPlanRecorder.hpp +++ b/src/libs/database/include/database/profiling/IQueryProfiler.hpp @@ -19,21 +19,35 @@ #pragma once +#include #include #include +#include namespace lms::db { // Due to technical limitations, query plans are recorded globally across all databases. // As a result, this class is implemented as a singleton rather than being owned per DB instance. - class IQueryPlanRecorder + class IQueryProfiler { public: - virtual ~IQueryPlanRecorder() = default; + virtual ~IQueryProfiler() = default; - using QueryPlanVisitor = std::function; - virtual void visitQueryPlans(const QueryPlanVisitor& visitor) const = 0; + using Clock = std::chrono::steady_clock; + + struct QueryStats + { + std::string_view query; + std::string_view plan; + std::size_t callCount{}; + std::chrono::microseconds totalTime{}; + std::chrono::microseconds meanTime{}; + std::chrono::microseconds stdDevTime{}; + }; + + using QueryVisitor = std::function; + virtual void visitQueries(const QueryVisitor& visitor) const = 0; }; - std::unique_ptr createQueryPlanRecorder(); + std::unique_ptr createQueryProfiler(); } // namespace lms::db diff --git a/src/libs/database/test/CMakeLists.txt b/src/libs/database/test/CMakeLists.txt index a5cef6da..5f619c27 100644 --- a/src/libs/database/test/CMakeLists.txt +++ b/src/libs/database/test/CMakeLists.txt @@ -27,7 +27,6 @@ add_executable(test-database TrackArtistLink.cpp TrackBookmark.cpp TrackEmbeddedImage.cpp - TrackFeatures.cpp TrackList.cpp TrackLyrics.cpp User.cpp @@ -36,6 +35,7 @@ add_executable(test-database target_link_libraries(test-database PRIVATE lmsdatabase GTest::GTest + GTest::gtest_main ) if (NOT CMAKE_CROSSCOMPILING) diff --git a/src/libs/database/test/Cluster.cpp b/src/libs/database/test/Cluster.cpp index 9a0b49c9..4eb6d57e 100644 --- a/src/libs/database/test/Cluster.cpp +++ b/src/libs/database/test/Cluster.cpp @@ -568,81 +568,6 @@ namespace lms::db::tests } } - TEST_F(DatabaseFixture, MultipleTracksSingleClusterSimilarity) - { - std::list tracks; - ScopedClusterType clusterType{ session, "MyClusterType" }; - ScopedCluster cluster{ session, clusterType.lockAndGet(), "MyClusterType" }; - - for (std::size_t i{}; i < 10; ++i) - { - tracks.emplace_back(session); - - { - auto transaction{ session.createWriteTransaction() }; - cluster.get().modify()->addTrack(tracks.back().get()); - } - } - - { - auto transaction{ session.createReadTransaction() }; - - const auto similarTracks{ Track::findSimilarTrackIds(session, { tracks.front().getId() }) }; - EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1); - for (const TrackId similarTrackId : similarTracks.results) - { - EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); - } - } - } - - TEST_F(DatabaseFixture, MultipleTracksMultipleClustersSimilarity) - { - std::list tracks; - ScopedClusterType clusterType{ session, "MyClusterType" }; - ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; - ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - - for (std::size_t i{}; i < 5; ++i) - { - tracks.emplace_back(session); - - { - auto transaction{ session.createWriteTransaction() }; - cluster1.get().modify()->addTrack(tracks.back().get()); - } - } - - for (std::size_t i{ 5 }; i < 10; ++i) - { - tracks.emplace_back(session); - - { - auto transaction{ session.createWriteTransaction() }; - cluster1.get().modify()->addTrack(tracks.back().get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - } - - { - auto transaction{ session.createReadTransaction() }; - - { - auto similarTracks{ Track::findSimilarTrackIds(session, { tracks.back().getId() }, Range{ 0, 4 }) }; - EXPECT_EQ(similarTracks.results.size(), 4); - for (const TrackId similarTrackId : similarTracks.results) - EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 5), std::next(std::cend(tracks), -1), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); - } - - { - auto similarTracks{ Track::findSimilarTrackIds(session, { tracks.front().getId() }) }; - EXPECT_EQ(similarTracks.results.size(), tracks.size() - 1); - for (const TrackId similarTrackId : similarTracks.results) - EXPECT_TRUE(std::find_if(std::next(std::cbegin(tracks), 1), std::cend(tracks), [&](const auto& track) { return similarTrackId == track.getId(); }) != std::cend(tracks)); - } - } - } - TEST_F(DatabaseFixture, SingleTrackSingleReleaseSingleArtistSingleCluster) { ScopedTrack track{ session }; @@ -798,143 +723,4 @@ namespace lms::db::tests } } - TEST_F(DatabaseFixture, MultipleTracksMultipleArtistsMultiClusters) - { - ScopedArtist artist1{ session, "MyArtist1" }; - ScopedArtist artist2{ session, "MyArtist2" }; - ScopedArtist artist3{ session, "MyArtist3" }; - ScopedClusterType clusterType{ session, "MyClusterType" }; - ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; - ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - - { - auto transaction{ session.createReadTransaction() }; - EXPECT_EQ(artist1->findSimilarArtistIds().results.size(), 0); - EXPECT_EQ(artist2->findSimilarArtistIds().results.size(), 0); - EXPECT_EQ(artist3->findSimilarArtistIds().results.size(), 0); - } - - std::list tracks; - for (std::size_t i{}; i < 10; ++i) - { - tracks.emplace_back(session); - - auto transaction{ session.createWriteTransaction() }; - - if (i < 5) - session.create(tracks.back().get(), artist1.get(), TrackArtistLinkType::Artist); - else - { - session.create(tracks.back().get(), artist2.get(), TrackArtistLinkType::Artist); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - cluster1.get().modify()->addTrack(tracks.back().get()); - } - - tracks.emplace_back(session); - { - auto transaction{ session.createWriteTransaction() }; - session.create(tracks.back().get(), artist3.get(), TrackArtistLinkType::Artist); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - { - auto transaction{ session.createReadTransaction() }; - - { - auto artists{ artist1->findSimilarArtistIds() }; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist2.getId()); - } - - { - auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Artist }) }; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist2.getId()); - } - - { - auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Lyricist }) }; - EXPECT_EQ(artists.results.size(), 0); - } - - { - auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Artist, TrackArtistLinkType::Lyricist }) }; - ASSERT_EQ(artists.results.size(), 1); - EXPECT_EQ(artists.results.front(), artist2.getId()); - } - - { - auto artists{ artist1->findSimilarArtistIds({ TrackArtistLinkType::Composer }) }; - EXPECT_EQ(artists.results.size(), 0); - } - - { - auto artists{ artist2->findSimilarArtistIds() }; - ASSERT_EQ(artists.results.size(), 2); - EXPECT_EQ(artists.results[0], artist1.getId()); - EXPECT_EQ(artists.results[1], artist3.getId()); - } - } - } - - TEST_F(DatabaseFixture, MultipleTracksMultipleReleasesMultiClusters) - { - ScopedRelease release1{ session, "MyRelease1" }; - ScopedRelease release2{ session, "MyRelease2" }; - ScopedRelease release3{ session, "MyRelease3" }; - ScopedClusterType clusterType{ session, "MyClusterType" }; - ScopedCluster cluster1{ session, clusterType.lockAndGet(), "MyCluster1" }; - ScopedCluster cluster2{ session, clusterType.lockAndGet(), "MyCluster2" }; - - { - auto transaction{ session.createReadTransaction() }; - EXPECT_EQ(release1->getSimilarReleases().size(), 0); - EXPECT_EQ(release2->getSimilarReleases().size(), 0); - EXPECT_EQ(release3->getSimilarReleases().size(), 0); - } - - std::list tracks; - for (std::size_t i{}; i < 10; ++i) - { - tracks.emplace_back(session); - - auto transaction{ session.createWriteTransaction() }; - - if (i < 5) - tracks.back().get().modify()->setRelease(release1.get()); - else - { - tracks.back().get().modify()->setRelease(release2.get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - cluster1.get().modify()->addTrack(tracks.back().get()); - } - - tracks.emplace_back(session); - { - auto transaction{ session.createWriteTransaction() }; - tracks.back().get().modify()->setRelease(release3.get()); - cluster2.get().modify()->addTrack(tracks.back().get()); - } - - { - auto transaction{ session.createReadTransaction() }; - - { - auto releases{ release1->getSimilarReleases() }; - ASSERT_EQ(releases.size(), 1); - EXPECT_EQ(releases.front()->getId(), release2.getId()); - } - - { - auto releases{ release2->getSimilarReleases() }; - ASSERT_EQ(releases.size(), 2); - EXPECT_EQ(releases[0]->getId(), release1.getId()); - EXPECT_EQ(releases[1]->getId(), release3.getId()); - } - } - } } // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/database/test/Common.hpp b/src/libs/database/test/Common.hpp index 7a025976..26ffa066 100644 --- a/src/libs/database/test/Common.hpp +++ b/src/libs/database/test/Common.hpp @@ -36,7 +36,6 @@ #include "database/objects/Track.hpp" #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackBookmark.hpp" -#include "database/objects/TrackFeatures.hpp" #include "database/objects/TrackList.hpp" #include "database/objects/User.hpp" @@ -142,9 +141,8 @@ namespace lms::db::tests class DatabaseFixture : public ::testing::Test { public: - ~DatabaseFixture(); + ~DatabaseFixture() override; - public: static void SetUpTestCase(); static void TearDownTestCase(); diff --git a/src/libs/database/test/DatabaseTest.cpp b/src/libs/database/test/DatabaseTest.cpp index ac9ec205..99ac564a 100644 --- a/src/libs/database/test/DatabaseTest.cpp +++ b/src/libs/database/test/DatabaseTest.cpp @@ -102,9 +102,3 @@ namespace lms::db::tests } } } // namespace lms::db::tests - -int main(int argc, char** argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/src/libs/database/test/TrackFeatures.cpp b/src/libs/database/test/TrackFeatures.cpp deleted file mode 100644 index edf56025..00000000 --- a/src/libs/database/test/TrackFeatures.cpp +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (C) 2021 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include "Common.hpp" - -#include "database/objects/TrackFeatures.hpp" - -namespace lms::db::tests -{ - using ScopedTrackFeatures = ScopedEntity; - - TEST_F(DatabaseFixture, TrackFeatures) - { - ScopedTrack track{ session }; - ScopedUser user{ session, "MyUser" }; - - { - auto transaction{ session.createReadTransaction() }; - EXPECT_EQ(TrackFeatures::getCount(session), 0); - } - - ScopedTrackFeatures trackFeatures{ session, track.lockAndGet(), "" }; - - { - auto transaction{ session.createWriteTransaction() }; - EXPECT_EQ(TrackFeatures::getCount(session), 1); - - auto allTrackFeatures{ TrackFeatures::find(session) }; - ASSERT_EQ(allTrackFeatures.results.size(), 1); - EXPECT_EQ(allTrackFeatures.results.front(), trackFeatures.getId()); - } - } -} // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/math/CMakeLists.txt b/src/libs/math/CMakeLists.txt new file mode 100644 index 00000000..dcb75261 --- /dev/null +++ b/src/libs/math/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(lmsmath INTERFACE) + +target_include_directories(lmsmath INTERFACE + include +) + +if(BUILD_TESTING) + add_subdirectory(test) +endif() + +if (BUILD_BENCHMARKS) + add_subdirectory(bench) +endif() + diff --git a/src/libs/math/bench/CMakeLists.txt b/src/libs/math/bench/CMakeLists.txt new file mode 100644 index 00000000..40d33fd9 --- /dev/null +++ b/src/libs/math/bench/CMakeLists.txt @@ -0,0 +1,19 @@ + +add_executable(bench-math + ChamferDistance.cpp + CosineDistance.cpp + DotProduct.cpp + EuclideanDistance.cpp + FFT.cpp + Math.cpp + ) + +target_link_libraries(bench-math PRIVATE + lmscore + lmsmath + benchmark + ) + +target_compile_options(bench-math PRIVATE + $<$>:-ffast-math> + ) diff --git a/src/libs/math/bench/ChamferDistance.cpp b/src/libs/math/bench/ChamferDistance.cpp new file mode 100644 index 00000000..ccd76de5 --- /dev/null +++ b/src/libs/math/bench/ChamferDistance.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include + +#include + +#include "core/Random.hpp" + +#include "math/ChamferDistance.hpp" +#include "math/Vector.hpp" + +namespace lms::core::benchs +{ + template + struct BenchDistance + { + BenchDistance(const math::Vector& ref) + : _ref{ ref } {} + + float operator()(const math::Vector& b) const + { + float sum{}; + for (std::size_t i{}; i < Size; ++i) + { + const float diff{ _ref[i] - b[i] }; + sum += diff * diff; + } + return std::sqrt(sum); + } + + const math::Vector& _ref; + }; + + template + static void BM_ChamferDistanceAtoB(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + std::vector> vecA; + std::vector> vecB; + vecA.reserve(SetASize); + vecB.reserve(SetBSize); + + for (std::size_t i{}; i < SetASize; ++i) + { + auto& vec{ vecA.emplace_back() }; + core::random::fillContainer(randomEngine, vec, 0.F, 1.F); + } + + for (std::size_t i{}; i < SetBSize; ++i) + { + auto& vec{ vecB.emplace_back() }; + core::random::fillContainer(randomEngine, vec, 0.F, 1.F); + } + + for (auto _ : state) + { + benchmark::DoNotOptimize(math::chamferDistanceAtoB>(vecA, vecB)); + } + + state.SetItemsProcessed(state.iterations() * SetASize * SetBSize); + } + + template + static void BM_SymmetricalChamferDistance(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + std::vector> vecA; + std::vector> vecB; + vecA.reserve(SetASize); + vecB.reserve(SetBSize); + + for (std::size_t i{}; i < SetASize; ++i) + { + auto& vec{ vecA.emplace_back() }; + core::random::fillContainer(randomEngine, vec, 0.F, 1.F); + } + + for (std::size_t i{}; i < SetBSize; ++i) + { + auto& vec{ vecB.emplace_back() }; + core::random::fillContainer(randomEngine, vec, 0.F, 1.F); + } + + for (auto _ : state) + { + benchmark::DoNotOptimize(math::symmetricalChamferDistance>(vecA, vecB)); + } + + state.SetItemsProcessed(state.iterations() * 2 * SetASize * SetBSize); + } + + // Benchmarks with different configurations + BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 128, 10, 10); + BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 128, 50, 50); + BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 256, 10, 10); + BENCHMARK_TEMPLATE(BM_ChamferDistanceAtoB, 256, 50, 50); + + BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 128, 10, 10); + BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 128, 50, 50); + BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 256, 10, 10); + BENCHMARK_TEMPLATE(BM_SymmetricalChamferDistance, 256, 50, 50); +} // namespace lms::core::benchs diff --git a/src/libs/math/bench/CosineDistance.cpp b/src/libs/math/bench/CosineDistance.cpp new file mode 100644 index 00000000..819ad91f --- /dev/null +++ b/src/libs/math/bench/CosineDistance.cpp @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include + +#include "core/Random.hpp" + +#include "math/CosineDistance.hpp" +#include "math/NormalizedCosineDistance.hpp" + +namespace lms::math::benchs +{ + template + static void BM_CosineDistance(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + Vector vec1; + Vector vec2; + + core::random::fillContainer(randomEngine, vec1, 0.F, 1.F); + core::random::fillContainer(randomEngine, vec2, 0.F, 1.F); + + for (auto _ : state) + { + benchmark::DoNotOptimize(computeCosineDistance(vec1, vec2)); + } + + state.SetItemsProcessed(state.iterations() * Size); + } + + template + static void BM_NormalizedCosineDistance(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + Vector vec1; + Vector vec2; + + core::random::fillContainer(randomEngine, vec1, 0.F, 1.F); + core::random::fillContainer(randomEngine, vec2, 0.F, 1.F); + + // normalize once, the normalized distance assumes L2-normalized vectors + vec1.normalizeL2(); + vec2.normalizeL2(); + + for (auto _ : state) + { + benchmark::DoNotOptimize(computeNormalizedCosineDistance(vec1, vec2)); + } + + state.SetItemsProcessed(state.iterations() * Size); + } + + template + static void BM_NormalizedCosineDistance_Functor(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + Vector vec1; + Vector vec2; + + core::random::fillContainer(randomEngine, vec1, 0.F, 1.F); + core::random::fillContainer(randomEngine, vec2, 0.F, 1.F); + + vec1.normalizeL2(); + vec2.normalizeL2(); + + const NormalizedCosineDistance dist{ vec1 }; + + for (auto _ : state) + { + benchmark::DoNotOptimize(dist(vec2)); + } + + state.SetItemsProcessed(state.iterations() * Size); + } + + BENCHMARK_TEMPLATE(BM_CosineDistance, 4); + BENCHMARK_TEMPLATE(BM_CosineDistance, 50); + BENCHMARK_TEMPLATE(BM_CosineDistance, 160); + BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance, 4); + BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance, 50); + BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance, 160); + BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance_Functor, 4); + BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance_Functor, 50); + BENCHMARK_TEMPLATE(BM_NormalizedCosineDistance_Functor, 160); +} // namespace lms::math::benchs diff --git a/src/libs/math/bench/DotProduct.cpp b/src/libs/math/bench/DotProduct.cpp new file mode 100644 index 00000000..143fe230 --- /dev/null +++ b/src/libs/math/bench/DotProduct.cpp @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include + +#include "core/Random.hpp" + +#include "math/DotProduct.hpp" + +namespace lms::math::benchs +{ + template + static void BM_DotProduct(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + Vector vec1; + Vector vec2; + + core::random::fillContainer(randomEngine, vec1, 0.F, 1.F); + core::random::fillContainer(randomEngine, vec2, 0.F, 1.F); + + for (auto _ : state) + { + benchmark::DoNotOptimize(computeDotProduct(vec1, vec2)); + } + + state.SetItemsProcessed(state.iterations() * Size); + } + + BENCHMARK_TEMPLATE(BM_DotProduct, 4); + BENCHMARK_TEMPLATE(BM_DotProduct, 50); + BENCHMARK_TEMPLATE(BM_DotProduct, 160); +} // namespace lms::math::benchs diff --git a/src/libs/math/bench/EuclideanDistance.cpp b/src/libs/math/bench/EuclideanDistance.cpp new file mode 100644 index 00000000..e533c072 --- /dev/null +++ b/src/libs/math/bench/EuclideanDistance.cpp @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include + +#include "core/Random.hpp" + +#include "math/EuclideanDistance.hpp" + +namespace lms::core::benchs +{ + template + static void BM_SquaredEuclideanDistance(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + math::Vector vec1; + math::Vector vec2; + + core::random::fillContainer(randomEngine, vec1, 0.F, 1.F); + core::random::fillContainer(randomEngine, vec2, 0.F, 1.F); + + for (auto _ : state) + { + benchmark::DoNotOptimize(math::computeEuclideanSquaredDistance(vec1, vec2)); + } + + state.SetItemsProcessed(state.iterations() * Size); + } + + template + static void BM_SquaredEuclideanDistanceWithWeights(benchmark::State& state) + { + std::minstd_rand randomEngine{ 0 }; + + math::Vector vec1; + math::Vector vec2; + math::Vector weights; + + core::random::fillContainer(randomEngine, vec1, 0.F, 1.F); + core::random::fillContainer(randomEngine, vec2, 0.F, 1.F); + core::random::fillContainer(randomEngine, weights, 0.F, 1.F); + + for (auto _ : state) + { + benchmark::DoNotOptimize(math::computeEuclideanSquaredDistanceWithWeights(vec1, vec2, weights)); + } + + state.SetItemsProcessed(state.iterations() * Size); + } + + BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistance, 4); + BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistance, 50); + BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistance, 160); + BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistanceWithWeights, 4); + BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistanceWithWeights, 50); + BENCHMARK_TEMPLATE(BM_SquaredEuclideanDistanceWithWeights, 160); +} // namespace lms::core::benchs diff --git a/src/libs/math/bench/FFT.cpp b/src/libs/math/bench/FFT.cpp new file mode 100644 index 00000000..1dd903a7 --- /dev/null +++ b/src/libs/math/bench/FFT.cpp @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include +#include +#include + +#include + +#include "core/AlignedHeapArray.hpp" + +#include "math/FFT.hpp" + +namespace lms::math::benchs +{ + namespace + { + template + std::vector generateTestSignal(std::size_t n) + { + std::vector data(n); + + for (std::size_t i{}; i < n; ++i) + data[i] = std::sin(static_cast(2) * std::numbers::pi_v * static_cast(i) / static_cast(n)); + + return data; + } + } // namespace + + template + void BM_FFT(benchmark::State& state) + { + const std::vector inputSignal{ generateTestSignal(N) }; + + FixedRealFFTPlan fft; + core::AlignedHeapArray::minBufferAlignment> input{ N }; + core::AlignedHeapArray, FixedRealFFTPlan::minBufferAlignment> output{ fft.getOutputSize() }; + + std::copy(inputSignal.begin(), inputSignal.end(), input.begin()); + + for (auto _ : state) + fft.apply({ input.data(), input.size() }, { output.data(), output.size() }); + + state.counters["Samples/s"] = benchmark::Counter{ static_cast(N), benchmark::Counter::kIsIterationInvariantRate }; + state.counters["FFT/s"] = benchmark::Counter{ 1.0, benchmark::Counter::kIsIterationInvariantRate }; + } + + BENCHMARK(BM_FFT<512, float>); + BENCHMARK(BM_FFT<1024, float>); + BENCHMARK(BM_FFT<2048, float>); + BENCHMARK(BM_FFT<512, double>); + BENCHMARK(BM_FFT<1024, double>); + BENCHMARK(BM_FFT<2048, double>); + +} // namespace lms::math::benchs diff --git a/src/libs/math/bench/Math.cpp b/src/libs/math/bench/Math.cpp new file mode 100644 index 00000000..8915efc9 --- /dev/null +++ b/src/libs/math/bench/Math.cpp @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +BENCHMARK_MAIN(); \ No newline at end of file diff --git a/src/libs/math/include/math/CentroidCalculator.hpp b/src/libs/math/include/math/CentroidCalculator.hpp new file mode 100644 index 00000000..bf32facd --- /dev/null +++ b/src/libs/math/include/math/CentroidCalculator.hpp @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include + +namespace lms::math +{ + template + class CentroidCalculator + { + public: + static_assert(!std::is_const_v); + + using value_type = typename VectorType::value_type; + using size_type = std::size_t; + + constexpr void add(const VectorType& value) + { + _sum += value; + ++_count; + } + + template + constexpr void add(InputIt first, InputIt last) + { + for (; first != last; ++first) + add(*first); + } + + constexpr VectorType finalize() const + { + assert(_count > 0); + VectorType result{ _sum }; + result *= static_cast(1) / static_cast(_count); + return result; + } + + constexpr VectorType finalizeNormalized() const + { + VectorType result{ finalize() }; + result.normalizeL2(); + return result; + } + + constexpr bool empty() const + { + return _count == 0; + } + + constexpr size_type count() const + { + return _count; + } + + private: + VectorType _sum; + size_type _count{}; + }; + + template + constexpr VectorType computeCentroid(std::span values) + { + CentroidCalculator calculator; + calculator.add(std::cbegin(values), std::cend(values)); + return calculator.finalize(); + } + + template + constexpr VectorType computeNormalizedCentroid(std::span values) + { + CentroidCalculator calculator; + calculator.add(std::cbegin(values), std::cend(values)); + return calculator.finalizeNormalized(); + } +} // namespace lms::math diff --git a/src/libs/math/include/math/ChamferDistance.hpp b/src/libs/math/include/math/ChamferDistance.hpp new file mode 100644 index 00000000..65d629d3 --- /dev/null +++ b/src/libs/math/include/math/ChamferDistance.hpp @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace lms::math +{ + /// Computes the Chamfer distance from set A to set B. + /// For each element in A, finds the nearest element in B and sums these minimum distances. + /// The result is normalized by the size of A. + /// + /// @tparam DistanceFunc A functor type: constructed with an element of A as ref, + /// then called with each element of B as target. + /// @param A A forward range of vectors + /// @param B A forward range of vectors (same element type as A) + /// @return The normalized sum of minimum distances from A to B + template + requires std::same_as, + std::ranges::range_value_t> + auto chamferDistanceAtoB(const RangeA& A, const RangeB& B) + { + using Vector = std::ranges::range_value_t; + using ValueType = std::invoke_result_t; + + assert(!std::ranges::empty(A)); + assert(!std::ranges::empty(B)); + + ValueType total{}; + std::size_t countA{}; + + for (const auto& a : A) + { + DistanceFunc distFunc{ a }; + ValueType bestDist{ std::numeric_limits::max() }; + + for (const auto& b : B) + { + const ValueType dist{ distFunc(b) }; + if (dist < bestDist) + bestDist = dist; + } + + total += bestDist; + ++countA; + } + + return total / static_cast(countA); + } + + /// Computes the symmetrical Chamfer distance between two sets. + /// Returns the average of chamferDistanceAtoB(A, B) and chamferDistanceAtoB(B, A). + /// + /// @tparam DistanceFunc A functor type: constructed with the ref element, + /// then called with each candidate element. + /// @param A A forward range of vectors + /// @param B A forward range of vectors (same element type as A) + /// @return The symmetrical Chamfer distance + template + requires std::same_as, + std::ranges::range_value_t> + auto symmetricalChamferDistance(const RangeA& A, const RangeB& B) + { + const auto aToB{ chamferDistanceAtoB(A, B) }; + const auto bToA{ chamferDistanceAtoB(B, A) }; + return (aToB + bToA) / static_cast(2); + } +} // namespace lms::math diff --git a/src/libs/math/include/math/CosineDistance.hpp b/src/libs/math/include/math/CosineDistance.hpp new file mode 100644 index 00000000..3d091470 --- /dev/null +++ b/src/libs/math/include/math/CosineDistance.hpp @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +#include "math/Vector.hpp" + +namespace lms::math +{ + template + FloatType computeCosineDistance(const Vector& a, const Vector& b) + { + constexpr FloatType smallEpsilon{ 1e-12F }; + + FloatType dot{}; + FloatType lhsNormSquared{}; + FloatType rhsNormSquared{}; + + for (std::size_t i{}; i < Size; ++i) + { + dot += a[i] * b[i]; + lhsNormSquared += a[i] * a[i]; + rhsNormSquared += b[i] * b[i]; + } + + const FloatType denom{ std::sqrt(lhsNormSquared * rhsNormSquared) }; + if (denom <= smallEpsilon) + return FloatType{ 1.F }; + + FloatType cosineSimilarity{ dot / denom }; + cosineSimilarity = std::clamp(cosineSimilarity, FloatType{ -1.F }, FloatType{ 1.F }); + + return FloatType{ 1.F } - cosineSimilarity; + } + + template + struct CosineDistance + { + constexpr CosineDistance(const Vector& ref) + : _ref{ ref } + { + } + + FloatType operator()(const Vector& a) const + { + return computeCosineDistance(_ref, a); + } + + const Vector& _ref; + }; +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/include/math/CovarianceCalculator.hpp b/src/libs/math/include/math/CovarianceCalculator.hpp new file mode 100644 index 00000000..ca14543c --- /dev/null +++ b/src/libs/math/include/math/CovarianceCalculator.hpp @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include + +#include "math/SquareMatrix.hpp" +#include "math/Vector.hpp" + +namespace lms::math +{ + template + class CovarianceMatrixCalculator + { + public: + static_assert(!std::is_const_v); + + using CovarianceMatrix = SquareMatrix; + + constexpr void add(const Vector& centeredVector) + { + for (std::size_t i{}; i < Size; ++i) + { + for (std::size_t j{}; j <= i; ++j) + _cov[i][j] += centeredVector[i] * centeredVector[j]; + } + + ++_count; + } + + template + constexpr void add(InputIt first, InputIt last) + { + for (; first != last; ++first) + add(*first); + } + + constexpr void finalizeSample(CovarianceMatrix& out) const + { + out.fill(FloatType{}); + if (_count < 2) + return; + + const FloatType divisor{ static_cast(_count - 1) }; + for (std::size_t i{}; i < Size; ++i) + { + for (std::size_t j{}; j <= i; ++j) + { + const FloatType value{ _cov[i][j] / divisor }; + out[i][j] = value; + out[j][i] = value; + } + } + } + + constexpr void finalizePopulation(CovarianceMatrix& out) const + { + out.fill(FloatType{}); + if (_count == 0) + return; + + const FloatType divisor{ static_cast(_count) }; + for (std::size_t i{}; i < Size; ++i) + { + for (std::size_t j{}; j <= i; ++j) + { + const FloatType value{ _cov[i][j] / divisor }; + out[i][j] = value; + out[j][i] = value; + } + } + } + + constexpr bool empty() const + { + return _count == 0; + } + + constexpr std::size_t count() const + { + return _count; + } + + private: + CovarianceMatrix _cov{}; + std::size_t _count{}; + }; +} // namespace lms::math diff --git a/src/libs/math/include/math/DotProduct.hpp b/src/libs/math/include/math/DotProduct.hpp new file mode 100644 index 00000000..e3ee17a9 --- /dev/null +++ b/src/libs/math/include/math/DotProduct.hpp @@ -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 . + */ + +#pragma once + +#include "math/Vector.hpp" + +namespace lms::math +{ + template + constexpr FloatType computeDotProduct(const Vector& a, const Vector& b) + { + FloatType res{}; + + for (std::size_t i{}; i < Size; ++i) + res += a[i] * b[i]; + + return res; + } + + template + struct DotProduct + { + constexpr DotProduct(const Vector& ref) + : _ref{ ref } + { + } + + constexpr FloatType operator()(const Vector& a) const + { + return computeDotProduct(_ref, a); + } + + const Vector& _ref; + }; +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/include/math/Entropy.hpp b/src/libs/math/include/math/Entropy.hpp new file mode 100644 index 00000000..18bb2702 --- /dev/null +++ b/src/libs/math/include/math/Entropy.hpp @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +namespace lms::math +{ + template + FloatType entropy(std::span c) + { + FloatType sum{}; + + for (auto v : c) + sum += v; + + constexpr FloatType epsilon{ 1e-12 }; + + if (sum <= epsilon) + return {}; + + const FloatType invSum{ FloatType{ 1 } / sum }; + + FloatType res{}; + for (auto v : c) + { + if (v <= epsilon) + continue; + + const FloatType p{ v * invSum }; + res -= p * std::log(p); + } + + return res; + } +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/include/math/EuclideanDistance.hpp b/src/libs/math/include/math/EuclideanDistance.hpp new file mode 100644 index 00000000..e660b8a2 --- /dev/null +++ b/src/libs/math/include/math/EuclideanDistance.hpp @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "math/Vector.hpp" + +namespace lms::math +{ + template + constexpr FloatType computeEuclideanSquaredDistance(const Vector& a, const Vector& b) + { + FloatType res{}; + + for (std::size_t i{}; i < Size; ++i) + { + const FloatType diff{ a[i] - b[i] }; + res += diff * diff; + } + + return res; + } + + template + constexpr FloatType computeEuclideanSquaredDistanceWithWeights(const Vector& a, const Vector& b, const Vector& weights) + { + FloatType res{}; + + for (std::size_t i{}; i < Size; ++i) + { + const FloatType diff{ a[i] - b[i] }; + res += diff * diff * weights[i]; + } + + return res; + } + + template + struct SquaredEuclideanDistance + { + constexpr SquaredEuclideanDistance(const Vector& ref) + : _ref{ ref } {} + + constexpr FloatType operator()(const Vector& a) const + { + return computeEuclideanSquaredDistance(_ref, a); + } + + const Vector& _ref; + }; + + template + struct SquaredEuclideanDistanceWithWeights + { + constexpr SquaredEuclideanDistanceWithWeights(const Vector& ref, const Vector& weights) + : _ref{ ref } + , _weights{ weights } + { + } + + constexpr FloatType operator()(const Vector& a) const + { + return computeEuclideanSquaredDistanceWithWeights(_ref, a, _weights); + } + + const Vector& _ref; + const Vector& _weights; + }; +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/include/math/FFT.hpp b/src/libs/math/include/math/FFT.hpp new file mode 100644 index 00000000..948ec88b --- /dev/null +++ b/src/libs/math/include/math/FFT.hpp @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lms::math +{ + template + class FixedRealFFTPlan + { + static_assert(std::has_single_bit(Size), "Size must be power of two"); + + public: + static constexpr std::size_t minBufferAlignment{ 32 }; + + constexpr FixedRealFFTPlan() + { + // Twiddles + for (std::size_t k{}; k < halfSize; ++k) + { + FloatType angle{ FloatType(-2) * std::numbers::pi_v * k / Size }; + _twiddles[k] = std::complex{ std::cos(angle), std::sin(angle) }; + } + + // Bit-reversal for halfSize FFT + constexpr std::size_t logHalf{ std::countr_zero(halfSize) }; + for (std::size_t i{}; i < halfSize; ++i) + _bitrev[i] = reverseBits(i, logHalf); + } + + constexpr static std::size_t getInputSize() noexcept { return Size; } + constexpr static std::size_t getOutputSize() noexcept { return halfSize + 1; } + + constexpr void apply(std::span input, std::span> output) const noexcept + { + assert(input.size() == Size); + assert(output.size() == halfSize + 1); + + assert(reinterpret_cast(input.data()) % minBufferAlignment == 0); + assert(reinterpret_cast(output.data()) % minBufferAlignment == 0); + + // Pack real -> complex + alignas(minBufferAlignment) std::array, halfSize> data; + for (std::size_t i{}; i < halfSize; ++i) + data[i] = std::complex{ input[2 * i], input[2 * i + 1] }; + + fft(data); + + // Real FFT post-process + output[0] = std::complex{ data[0].real() + data[0].imag(), FloatType{} }; + output[halfSize] = std::complex{ data[0].real() - data[0].imag(), FloatType{} }; + for (std::size_t k{ 1 }; k <= halfSize / 2; ++k) + { + const auto a{ data[k] }; + const auto b{ std::conj(data[(halfSize - k) & (halfSize - 1)]) }; + + const auto even{ (a + b) * std::complex{ FloatType(0.5), FloatType{} } }; + const auto odd{ (a - b) * std::complex{ FloatType{}, FloatType(-0.5) } }; + + const auto& W{ _twiddles[k] }; + const auto t{ W * odd }; + + output[k] = even + t; + output[halfSize - k] = std::conj(even - t); + } + } + + private: + static constexpr std::size_t halfSize{ Size / 2 }; + + alignas(minBufferAlignment) std::array, halfSize> _twiddles{}; + std::array _bitrev{}; + + static constexpr std::size_t reverseBits(std::size_t x, std::size_t bitCount) noexcept + { + std::size_t y{}; + for (std::size_t i{}; i < bitCount; ++i) + { + y = (y << 1) | (x & 1); + x >>= 1; + } + return y; + } + + constexpr void fft(std::array, halfSize>& data) const noexcept + { + // Bit reversal + for (std::size_t i{}; i < halfSize; ++i) + { + const auto j{ _bitrev[i] }; + if (i < j) + std::swap(data[i], data[j]); + } + + fftStages<1>(data); + } + + template + constexpr void fftStages(std::array, halfSize>& data) const noexcept + { + constexpr std::size_t len{ 1U << Stage }; + + if constexpr (len <= halfSize) + { + constexpr std::size_t half{ len >> 1 }; + constexpr std::size_t step{ Size / len }; + + for (std::size_t i{}; i < halfSize; i += len) + { + for (std::size_t j{}; j < half; ++j) + { + auto& u{ data[i + j] }; + auto& v{ data[i + j + half] }; + + const auto t{ _twiddles[j * step] * v }; + + v = u - t; + u = u + t; + } + } + + fftStages(data); + } + } + }; +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/include/math/MedoidCalculator.hpp b/src/libs/math/include/math/MedoidCalculator.hpp new file mode 100644 index 00000000..f2893ff4 --- /dev/null +++ b/src/libs/math/include/math/MedoidCalculator.hpp @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "math/EuclideanDistance.hpp" + +namespace lms::math +{ + template + class MedoidCalculator + { + public: + static_assert(!std::is_const_v); + + using value_type = typename VectorType::value_type; + using size_type = std::size_t; + + // Add a single vector, returning its index + size_type add(const VectorType& value) + { + _vectors.push_back(value); + return _vectors.size() - 1; + } + + // Compute the medoid: the vector with minimum sum of squared distances to all others + // Returns the index of the medoid in the added vectors + size_type findMedoidIndex() const + { + assert(!empty()); + + size_type medoidIndex{}; + value_type minTotalDistance{ std::numeric_limits::max() }; + + for (size_type i{}; i < _vectors.size(); ++i) + { + value_type totalDistance{}; + const SquaredEuclideanDistance distFunc{ _vectors[i] }; + for (size_type j{}; j < _vectors.size(); ++j) + { + if (i != j) + totalDistance += distFunc(_vectors[j]); + } + + if (totalDistance < minTotalDistance) + { + minTotalDistance = totalDistance; + medoidIndex = i; + } + } + + return medoidIndex; + } + + // Compute the medoid vector itself + VectorType finalize() const + { + return _vectors[findMedoidIndex()]; + } + + // Get a specific vector by index + const VectorType& getVector(size_type index) const + { + assert(index < _vectors.size()); + return _vectors[index]; + } + + // Query methods + bool empty() const + { + return _vectors.empty(); + } + + size_type count() const + { + return _vectors.size(); + } + + void clear() + { + _vectors.clear(); + } + + private: + std::vector _vectors; + }; + + template + VectorType computeMedoid(std::span values) + { + MedoidCalculator calculator; + for (const auto& value : values) + calculator.add(value); + return calculator.finalize(); + } + + template + VectorType computeNormalizedMedoid(std::span values) + { + MedoidCalculator calculator; + for (const auto& value : values) + calculator.add(value); + return calculator.finalizeNormalized(); + } +} // namespace lms::math diff --git a/src/libs/math/include/math/NormalizedCosineDistance.hpp b/src/libs/math/include/math/NormalizedCosineDistance.hpp new file mode 100644 index 00000000..b6ad89ff --- /dev/null +++ b/src/libs/math/include/math/NormalizedCosineDistance.hpp @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include + +#include "math/DotProduct.hpp" +#include "math/Vector.hpp" + +namespace lms::math +{ + // Returns the cosine distance in [0, 1] for L2-normalized vectors: + // 0 = identical direction, 0.5 = orthogonal, 1 = opposite directions. + template + constexpr FloatType computeNormalizedCosineDistance( + const Vector& a, + const Vector& b) + { + return (FloatType{ 1 } - computeDotProduct(a, b)) / FloatType{ 2 }; + } + + template + struct NormalizedCosineDistance + { + constexpr NormalizedCosineDistance(const Vector& ref) + : _ref{ ref } + { + } + + constexpr FloatType operator()(const Vector& a) const + { + return (FloatType{ 1 } - computeDotProduct(_ref, a)) / FloatType{ 2 }; + } + + const Vector& _ref; + }; +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/include/math/PrincipalComponents.hpp b/src/libs/math/include/math/PrincipalComponents.hpp new file mode 100644 index 00000000..dbceb2fb --- /dev/null +++ b/src/libs/math/include/math/PrincipalComponents.hpp @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "math/SquareMatrix.hpp" +#include "math/Vector.hpp" + +namespace lms::math +{ + template + FloatType dotProduct(const Vector& a, const Vector& b) + { + FloatType result{}; + for (std::size_t i{}; i < Size; ++i) + result += a[i] * b[i]; + return result; + } + + template + void computeEigenpairsViaPowerIteration(const SquareMatrix& covariance, + std::array, Size>& eigenvectors, + Vector& eigenvalues, + std::size_t maxIterations = 200, + FloatType epsilon = static_cast(1e-15)) + { + // Power iteration with Deflation for computing eigendecomposition. + // Iteratively finds the largest eigenvalue and corresponding eigenvector, + // then removes it from the matrix and repeats. + + auto covarianceCopy{ std::make_unique>(covariance) }; + std::minstd_rand rng{ 42 }; + std::uniform_real_distribution dist{ static_cast(-1.0), static_cast(1.0) }; + + for (std::size_t k{}; k < Size; ++k) + { + Vector v; + for (std::size_t i{}; i < Size; ++i) + v[i] = dist(rng); + + FloatType prevEigenvalue{}; + for (std::size_t iter{}; iter < maxIterations; ++iter) + { + Vector Av; + for (std::size_t i{}; i < Size; ++i) + { + for (std::size_t j{}; j < Size; ++j) + Av[i] += (*covarianceCopy)[i][j] * v[j]; + } + + FloatType normSquared{}; + for (std::size_t i{}; i < Size; ++i) + normSquared += Av[i] * Av[i]; + + if (normSquared < epsilon) + break; + + const FloatType norm{ std::sqrt(normSquared) }; + for (std::size_t i{}; i < Size; ++i) + v[i] = Av[i] / norm; + + eigenvalues[k] = norm; + + // Early exit if eigenvalue converged + if (iter > 0 && std::abs(norm - prevEigenvalue) < epsilon) + break; + + prevEigenvalue = norm; + } + + eigenvectors[k] = v; + + // Deflate matrix: A = A - lambda * v * v^T + for (std::size_t i{}; i < Size; ++i) + { + for (std::size_t j{}; j < Size; ++j) + (*covarianceCopy)[i][j] -= eigenvalues[k] * v[i] * v[j]; + } + } + } + + template + void projectOntoBasis(const std::array, BasisCount>& basis, + const Vector& centered, + Vector& output, + const std::array& scales) + { + for (std::size_t k{}; k < BasisCount; ++k) + { + FloatType sum{}; + for (std::size_t j{}; j < FeatureCount; ++j) + sum += basis[k][j] * centered[j]; + output[k] = sum * scales[k]; + } + } + + template + FloatType pearsonCorrelation(const Vector& a, const Vector& b) + { + static_assert(Size > 0); + + FloatType meanA{}; + FloatType meanB{}; + for (std::size_t i{}; i < Size; ++i) + { + meanA += a[i]; + meanB += b[i]; + } + + meanA /= static_cast(Size); + meanB /= static_cast(Size); + + FloatType cov{}; + FloatType varA{}; + FloatType varB{}; + for (std::size_t i{}; i < Size; ++i) + { + const FloatType da{ a[i] - meanA }; + const FloatType db{ b[i] - meanB }; + cov += da * db; + varA += da * da; + varB += db * db; + } + + if (varA <= static_cast(0) || varB <= static_cast(0)) + return static_cast(0); + + return cov / std::sqrt(varA * varB); + } +} // namespace lms::math diff --git a/src/libs/math/include/math/SquareMatrix.hpp b/src/libs/math/include/math/SquareMatrix.hpp new file mode 100644 index 00000000..e3149c54 --- /dev/null +++ b/src/libs/math/include/math/SquareMatrix.hpp @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace lms::math +{ + template + class SquareMatrix + { + public: + static_assert(N > 0, "SquareMatrix size must be positive"); + + using Row = std::array; + + constexpr SquareMatrix() = default; + + constexpr explicit SquareMatrix(const T& value) + { + fill(value); + } + + constexpr void fill(const T& value) + { + for (auto& row : _values) + row.fill(value); + } + + constexpr std::size_t size() const noexcept + { + return N; + } + + constexpr Row& operator[](std::size_t index) + { + assert(index < N); + return _values[index]; + } + + constexpr const Row& operator[](std::size_t index) const + { + assert(index < N); + return _values[index]; + } + + constexpr auto begin() + { + return _values.begin(); + } + + constexpr auto end() + { + return _values.end(); + } + + constexpr auto begin() const + { + return _values.begin(); + } + + constexpr auto end() const + { + return _values.end(); + } + + constexpr auto cbegin() const + { + return _values.cbegin(); + } + + constexpr auto cend() const + { + return _values.cend(); + } + + private: + std::array _values{}; + }; + + template + bool choleskyDecompose(const SquareMatrix& A, SquareMatrix& L) + { + static_assert(std::is_floating_point_v, "Cholesky decomposition requires floating point type"); + + L.fill(T{}); + + for (std::size_t i{}; i < N; ++i) + { + for (std::size_t j{}; j <= i; ++j) + { + T sum{}; + + for (std::size_t k{}; k < j; ++k) + sum += L[i][k] * L[j][k]; + + if (i == j) + { + const T val{ A[i][i] - sum }; + if (val <= T{}) + return false; + + L[i][j] = std::sqrt(val); + } + else + { + L[i][j] = (A[i][j] - sum) / L[j][j]; + } + } + } + + return true; + } + + template + void invertLowerTriangular(const SquareMatrix& L, SquareMatrix& Linv) + { + static_assert(std::is_floating_point_v, "Requires floating point type"); + + Linv.fill(T{}); + + for (std::size_t i{}; i < N; ++i) + { + assert(std::abs(L[i][i]) > std::numeric_limits::epsilon()); + Linv[i][i] = T{ 1 } / L[i][i]; + + for (std::size_t j{}; j < i; ++j) + { + T sum{}; + + for (std::size_t k{ j }; k < i; ++k) + sum += L[i][k] * Linv[k][j]; + + Linv[i][j] = -sum / L[i][i]; + } + } + } + + template + T computeSymmetryMaxDiff(const SquareMatrix& M) + { + static_assert(std::is_floating_point_v, "Requires floating point type"); + + T maxDiff{}; + + for (std::size_t i{}; i < N; ++i) + { + for (std::size_t j{ i + 1 }; j < N; ++j) + { + const T diff{ std::abs(M[i][j] - M[j][i]) }; + maxDiff = std::max(maxDiff, diff); + } + } + + return maxDiff; + } +} // namespace lms::math diff --git a/src/libs/math/include/math/StatsAccumulator.hpp b/src/libs/math/include/math/StatsAccumulator.hpp new file mode 100644 index 00000000..66c7a9c9 --- /dev/null +++ b/src/libs/math/include/math/StatsAccumulator.hpp @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +namespace lms::math +{ + template + class StatsAccumulator + { + public: + constexpr void add(FloatType x); + constexpr std::size_t getCount() const; + constexpr FloatType getMean() const; + + constexpr FloatType getSampleStdDev() const; + constexpr FloatType getSampleVariance() const; + + constexpr FloatType getPopulationVariance() const; + constexpr FloatType getPopulationStdDev() const; + + private: + std::size_t n{}; + double mean{}; + double M2{}; + }; + + template + inline constexpr void StatsAccumulator::add(FloatType x) + { + const double n1{ static_cast(n++) }; + const double nn{ static_cast(n) }; + + const double delta{ static_cast(x) - mean }; + const double delta_n{ delta / nn }; + const double term1{ delta * delta_n * n1 }; + + mean += delta_n; + M2 += term1; + } + + template + inline constexpr std::size_t StatsAccumulator::getCount() const + { + return n; + } + + template + inline constexpr FloatType StatsAccumulator::getMean() const + { + return static_cast(mean); + } + + template + inline constexpr FloatType StatsAccumulator::getSampleVariance() const + { + if (n < 2) + return FloatType{}; + + return static_cast(M2 / (n - 1)); + } + + template + inline constexpr FloatType StatsAccumulator::getPopulationVariance() const + { + if (n < 1) + return FloatType{}; + + return static_cast(M2 / n); + } + + template + inline constexpr FloatType StatsAccumulator::getSampleStdDev() const + { + return static_cast(std::sqrt(getSampleVariance())); + } + + template + inline constexpr FloatType StatsAccumulator::getPopulationStdDev() const + { + return static_cast(std::sqrt(getPopulationVariance())); + } +} // namespace lms::math diff --git a/src/libs/math/include/math/Vector.hpp b/src/libs/math/include/math/Vector.hpp new file mode 100644 index 00000000..55706593 --- /dev/null +++ b/src/libs/math/include/math/Vector.hpp @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2018 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +namespace lms::math +{ + template + class Vector + { + public: + static_assert(std::is_floating_point_v); + + using value_type = FloatType; + using Norm = FloatType; + using Distance = FloatType; + + constexpr explicit Vector(value_type initValue = value_type{}) + { + _values.fill(initValue); + } + + template + requires(sizeof...(Args) == Size) && (std::convertible_to && ...) + constexpr Vector(Args... args) + : _values{ static_cast(args)... } + { + } + + constexpr static std::size_t getSize() { return Size; } + + constexpr value_type* data() { return _values.data(); } + constexpr const value_type* data() const { return _values.data(); } + + constexpr value_type& operator[](std::size_t index) { return _values[index]; } + constexpr value_type operator[](std::size_t index) const { return _values[index]; } + + constexpr Vector& operator+=(const Vector& other) + { + for (std::size_t i{}; i < Size; ++i) + _values[i] += other[i]; + + return *this; + } + + constexpr Vector& operator-=(const Vector& other) + { + for (std::size_t i{}; i < Size; ++i) + _values[i] -= other[i]; + + return *this; + } + + constexpr Vector& operator*=(value_type factor) + { + for (std::size_t i{}; i < Size; ++i) + _values[i] *= factor; + + return *this; + } + + Norm computeNorm() const + { + Norm res{}; + for (value_type val : _values) + res += val * val; + return std::sqrt(res); + } + + void normalizeL2() + { + constexpr value_type smallEpsilon{ 1e-12 }; + + const Norm n{ computeNorm() }; + if (n > smallEpsilon) + { + for (value_type& v : _values) + v /= n; + } + } + + auto begin() { return std::begin(_values); } + auto begin() const { return std::begin(_values); } + auto cbegin() const { return std::cbegin(_values); } + auto end() { return std::end(_values); } + auto end() const { return std::end(_values); } + auto cend() const { return std::cend(_values); } + + private: + std::array _values; + }; + + template + constexpr Vector operator+(const Vector& a, const Vector& b) + { + Vector res{ a }; + res += b; + return res; + } + + template + constexpr Vector operator-(const Vector& a, const Vector& b) + { + Vector res{ a }; + res -= b; + return res; + } + + template + constexpr Vector operator*(const Vector& v, typename Vector::value_type scalar) + { + Vector res{ v }; + res *= scalar; + return res; + } + + template + constexpr Vector operator*(typename Vector::value_type scalar, const Vector& v) + { + return v * scalar; + } + +} // namespace lms::math diff --git a/src/libs/math/include/math/Window.hpp b/src/libs/math/include/math/Window.hpp new file mode 100644 index 00000000..8556fa73 --- /dev/null +++ b/src/libs/math/include/math/Window.hpp @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace lms::math +{ + template + class HannWindow + { + static_assert(FrameSize > 0, "FrameSize must be greater than zero"); + + public: + HannWindow() + { + if constexpr (FrameSize == 1) + { + _coefficients[0] = FloatType{ 1 }; + _energy = FloatType{ 1 }; + return; + } + + constexpr auto frameSize{ static_cast(FrameSize) }; + for (std::size_t i{}; i < FrameSize; ++i) + { + const auto coefficient{ static_cast(0.5) * (FloatType{ 1 } - std::cos(static_cast(2) * std::numbers::pi_v * static_cast(i) / (frameSize - FloatType{ 1 }))) }; + _coefficients[i] = coefficient; + _energy += coefficient * coefficient; + } + } + + [[nodiscard]] std::span values() const noexcept { return _coefficients; } + + [[nodiscard]] FloatType energy() const noexcept { return _energy; } + + void apply(std::span input, std::span output) const noexcept + { + assert(input.size() == FrameSize); + assert(output.size() == FrameSize); + + for (std::size_t i{}; i < FrameSize; ++i) + output[i] = input[i] * _coefficients[i]; + } + + private: + std::array _coefficients{}; + FloatType _energy{}; + }; +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/test/CMakeLists.txt b/src/libs/math/test/CMakeLists.txt new file mode 100644 index 00000000..9b84d6a4 --- /dev/null +++ b/src/libs/math/test/CMakeLists.txt @@ -0,0 +1,39 @@ +include(GoogleTest) + +add_executable(test-math + ChamferDistance.cpp + CentroidCalculator.cpp + CosineDistance.cpp + CovarianceCalculator.cpp + DotProduct.cpp + Entropy.cpp + EuclideanDistance.cpp + FFT.cpp + MedoidCalculator.cpp + NormalizedCosineDistance.cpp + PrincipalComponents.cpp + SquareMatrix.cpp + StatsAccumulator.cpp + Vector.cpp + Window.cpp +) + +target_include_directories(test-math PRIVATE + ../include +) + +target_link_libraries(test-math PRIVATE + lmscore + lmsmath + Threads::Threads + GTest::GTest + GTest::gtest_main +) + +target_compile_options(test-math PRIVATE + $<$>:-ffast-math> + ) + +if (NOT CMAKE_CROSSCOMPILING) + gtest_discover_tests(test-math) +endif() diff --git a/src/libs/math/test/CentroidCalculator.cpp b/src/libs/math/test/CentroidCalculator.cpp new file mode 100644 index 00000000..00fed409 --- /dev/null +++ b/src/libs/math/test/CentroidCalculator.cpp @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "math/CentroidCalculator.hpp" +#include "math/Vector.hpp" + +namespace lms::math::centroidCalculatorTests +{ + TEST(CentroidCalculator, initialState) + { + CentroidCalculator> calculator; + + EXPECT_TRUE(calculator.empty()); + EXPECT_EQ(calculator.count(), 0U); + } + + TEST(CentroidCalculator, addAndFinalize) + { + CentroidCalculator> calculator; + calculator.add(Vector<3, float>{ 1.0F, 2.0F, 3.0F }); + calculator.add(Vector<3, float>{ 4.0F, 5.0F, 6.0F }); + + const Vector<3, float> result = calculator.finalize(); + + EXPECT_FLOAT_EQ(result[0], 2.5F); + EXPECT_FLOAT_EQ(result[1], 3.5F); + EXPECT_FLOAT_EQ(result[2], 4.5F); + } + + TEST(CentroidCalculator, finalizeNormalized) + { + CentroidCalculator> calculator; + calculator.add(Vector<2, float>{ 3.0F, 4.0F }); + + const Vector<2, float> result = calculator.finalizeNormalized(); + + EXPECT_NEAR(result.computeNorm(), 1.0F, 1e-6F); + EXPECT_NEAR(result[0], 0.6F, 1e-6F); + EXPECT_NEAR(result[1], 0.8F, 1e-6F); + } + + TEST(CentroidCalculator, computeCentroidSpan) + { + const std::array, 2> values{ + Vector<2, float>{ 0.0F, 2.0F }, + Vector<2, float>{ 2.0F, 0.0F } + }; + + const Vector<2, float> result = computeCentroid(std::span>(values)); + + EXPECT_FLOAT_EQ(result[0], 1.0F); + EXPECT_FLOAT_EQ(result[1], 1.0F); + } +} // namespace lms::math::centroidCalculatorTests diff --git a/src/libs/math/test/ChamferDistance.cpp b/src/libs/math/test/ChamferDistance.cpp new file mode 100644 index 00000000..c0c23045 --- /dev/null +++ b/src/libs/math/test/ChamferDistance.cpp @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include + +#include "math/ChamferDistance.hpp" +#include "math/Vector.hpp" + +namespace lms::math::chamferDistanceTests +{ + constexpr float epsilon{ 1e-4F }; + + template + struct SimpleDistance + { + SimpleDistance(const Vector& ref) + : _ref{ ref } {} + + float operator()(const Vector& b) const + { + float sum{}; + for (std::size_t i{}; i < Size; ++i) + { + const float diff{ _ref[i] - b[i] }; + sum += diff * diff; + } + return std::sqrt(sum); + } + + const Vector& _ref; + }; + + TEST(ChamferDistance, singleElementSets) + { + const Vector<2, float> A[]{ { 0.F, 0.F } }; + const Vector<2, float> B[]{ { 3.F, 4.F } }; + + const float result{ chamferDistanceAtoB>(A, B) }; + + const float expected{ 5.F }; // sqrt(3^2 + 4^2) = 5 + EXPECT_NEAR(result, expected, epsilon); + } + + TEST(ChamferDistance, identicalSets) + { + const Vector<2, float> A[]{ { 1.F, 2.F }, { 3.F, 4.F } }; + + const float result{ chamferDistanceAtoB>(A, A) }; + + EXPECT_NEAR(result, 0.F, epsilon); + } + + TEST(ChamferDistance, asymmetricDistance) + { + // A = {(0,0), (1,0)}, B = {(0,0), (2,0)} + // For a=(0,0): min(dist to (0,0), dist to (2,0)) = 0 + // For a=(1,0): min(dist to (0,0), dist to (2,0)) = min(1, 1) = 1 + // Average = (0 + 1) / 2 = 0.5 + + const Vector<2, float> A[]{ { 0.F, 0.F }, { 1.F, 0.F } }; + const Vector<2, float> B[]{ { 0.F, 0.F }, { 2.F, 0.F } }; + + const float result{ chamferDistanceAtoB>(A, B) }; + + EXPECT_NEAR(result, 0.5F, epsilon); + } + + TEST(ChamferDistance, symmetricalDistance) + { + const Vector<2, float> A[]{ { 0.F, 0.F }, { 2.F, 0.F } }; + const Vector<2, float> B[]{ { 0.F, 0.F }, { 1.F, 0.F } }; + + const float symDist{ symmetricalChamferDistance>(A, B) }; + + const float aToB{ chamferDistanceAtoB>(A, B) }; + const float bToA{ chamferDistanceAtoB>(B, A) }; + const float expected{ (aToB + bToA) / 2.F }; + + EXPECT_NEAR(symDist, expected, epsilon); + } + + TEST(ChamferDistance, largerSets) + { + // A has 3 elements, B has 2 elements + const Vector<2, float> A[]{ { 0.F, 0.F }, { 1.F, 1.F }, { 2.F, 2.F } }; + const Vector<2, float> B[]{ { 0.F, 0.F }, { 3.F, 3.F } }; + + const float result{ chamferDistanceAtoB>(A, B) }; + + // a1: min(0, sqrt(27)) = 0 + // a2: min(sqrt(2), sqrt(8)) = sqrt(2) + // a3: min(sqrt(8), sqrt(2)) = sqrt(2) + // Average = (0 + sqrt(2) + sqrt(2)) / 3 = 2*sqrt(2) / 3 + const float expected{ 2.F * std::sqrt(2.F) / 3.F }; + + EXPECT_NEAR(result, expected, epsilon); + } + + TEST(ChamferDistance, negativeCoordinates) + { + const Vector<2, float> A[]{ { -1.F, -1.F } }; + const Vector<2, float> B[]{ { 1.F, 1.F } }; + + const float result{ chamferDistanceAtoB>(A, B) }; + + const float expected{ std::sqrt(8.F) }; // sqrt(2^2 + 2^2) + EXPECT_NEAR(result, expected, epsilon); + } + + TEST(ChamferDistance, higherDimensions) + { + const Vector<5, float> A[]{ { 1.F, 2.F, 3.F, 4.F, 5.F } }; + const Vector<5, float> B[]{ { 1.F, 2.F, 3.F, 4.F, 5.F } }; + + const float result{ chamferDistanceAtoB>(A, B) }; + + EXPECT_NEAR(result, 0.F, epsilon); + } +} // namespace lms::math::chamferDistanceTests diff --git a/src/libs/math/test/CosineDistance.cpp b/src/libs/math/test/CosineDistance.cpp new file mode 100644 index 00000000..1d86d2b8 --- /dev/null +++ b/src/libs/math/test/CosineDistance.cpp @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "math/CosineDistance.hpp" + +namespace lms::math::cosineDistanceTests +{ + constexpr float epsilon{ 1e-6F }; + + TEST(CosineDistance, equalVectors) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ 1.F, 2.F, 3.F }; + + EXPECT_NEAR(computeCosineDistance(a, b), 0.F, epsilon); + } + + TEST(CosineDistance, orthogonalVectors) + { + const Vector<3, float> a{ 1.F, 0.F, 0.F }; + const Vector<3, float> b{ 0.F, 1.F, 0.F }; + + EXPECT_NEAR(computeCosineDistance(a, b), 1.F, epsilon); + } + + TEST(CosineDistance, oppositeVectors) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ -1.F, -2.F, -3.F }; + + EXPECT_NEAR(computeCosineDistance(a, b), 2.F, epsilon); + } + + TEST(CosineDistance, zeroNormVector) + { + const Vector<3, float> a{ 0.F, 0.F, 0.F }; + const Vector<3, float> b{ 1.F, 2.F, 3.F }; + + EXPECT_FLOAT_EQ(computeCosineDistance(a, b), 1.F); + } + + TEST(CosineDistance, vectorMethod) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ 1.F, 2.F, 3.F }; + + EXPECT_NEAR(computeCosineDistance(a, b), 0.F, epsilon); + } + + TEST(CosineDistance, functor) + { + const Vector<3, float> reference{ 1.F, 0.F, 0.F }; + const Vector<3, float> candidate{ 0.F, 1.F, 0.F }; + const CosineDistance<3, float> distance{ reference }; + + EXPECT_NEAR(distance(candidate), 1.F, epsilon); + } +} // namespace lms::math::cosineDistanceTests \ No newline at end of file diff --git a/src/libs/math/test/CovarianceCalculator.cpp b/src/libs/math/test/CovarianceCalculator.cpp new file mode 100644 index 00000000..782656ec --- /dev/null +++ b/src/libs/math/test/CovarianceCalculator.cpp @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "math/CovarianceCalculator.hpp" +#include "math/SquareMatrix.hpp" +#include "math/Vector.hpp" + +namespace lms::math::covarianceCalculatorTests +{ + constexpr float epsilon{ 1e-6F }; + + TEST(CovarianceCalculator, empty) + { + CovarianceMatrixCalculator<2, float> calculator; + + EXPECT_TRUE(calculator.empty()); + EXPECT_EQ(calculator.count(), 0U); + } + + TEST(CovarianceCalculator, sampleCovariance) + { + CovarianceMatrixCalculator<2, float> calculator; + calculator.add({ 1.0F, 0.0F }); + calculator.add({ -1.0F, 0.0F }); + + SquareMatrix covariance; + calculator.finalizeSample(covariance); + + EXPECT_NEAR(covariance[0][0], 2.0F, epsilon); + EXPECT_NEAR(covariance[0][1], 0.0F, epsilon); + EXPECT_NEAR(covariance[1][0], 0.0F, epsilon); + EXPECT_NEAR(covariance[1][1], 0.0F, epsilon); + } + + TEST(CovarianceCalculator, populationCovariance) + { + CovarianceMatrixCalculator<2, float> calculator; + calculator.add(Vector<2, float>{ 1.0F, 0.0F }); + calculator.add(Vector<2, float>{ -1.0F, 0.0F }); + + SquareMatrix covariance; + calculator.finalizePopulation(covariance); + + EXPECT_NEAR(covariance[0][0], 1.0F, epsilon); + EXPECT_NEAR(covariance[0][1], 0.0F, epsilon); + EXPECT_NEAR(covariance[1][0], 0.0F, epsilon); + EXPECT_NEAR(covariance[1][1], 0.0F, epsilon); + } + + TEST(CovarianceCalculator, singleValueReturnsZero) + { + CovarianceMatrixCalculator<2, float> calculator; + calculator.add({ 1.0F, 2.0F }); + + SquareMatrix covariance; + calculator.finalizeSample(covariance); + + EXPECT_FLOAT_EQ(covariance[0][0], 0.0F); + EXPECT_FLOAT_EQ(covariance[0][1], 0.0F); + EXPECT_FLOAT_EQ(covariance[1][0], 0.0F); + EXPECT_FLOAT_EQ(covariance[1][1], 0.0F); + } +} // namespace lms::math::covarianceCalculatorTests diff --git a/src/libs/math/test/DotProduct.cpp b/src/libs/math/test/DotProduct.cpp new file mode 100644 index 00000000..f490ac15 --- /dev/null +++ b/src/libs/math/test/DotProduct.cpp @@ -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 . + */ + +#include + +#include "math/DotProduct.hpp" + +namespace lms::math::dotProductTests +{ + TEST(DotProduct, zeroLength) + { + const Vector<0, float> a{}; + const Vector<0, float> b{}; + + EXPECT_FLOAT_EQ(computeDotProduct(a, b), 0.F); + } + + TEST(DotProduct, simpleValues) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ 4.F, 5.F, 6.F }; + + EXPECT_FLOAT_EQ(computeDotProduct(a, b), 32.F); + } + + TEST(DotProduct, orthogonalVectors) + { + const Vector<3, float> a{ 1.F, 0.F, 0.F }; + const Vector<3, float> b{ 0.F, 1.F, 0.F }; + + EXPECT_FLOAT_EQ(computeDotProduct(a, b), 0.F); + } + + TEST(DotProduct, negativeValues) + { + const Vector<3, float> a{ -1.F, 2.F, -3.F }; + const Vector<3, float> b{ 4.F, -5.F, 6.F }; + + EXPECT_FLOAT_EQ(computeDotProduct(a, b), -32.F); + } + + TEST(DotProduct, vectorMethod) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ 4.F, 5.F, 6.F }; + + EXPECT_FLOAT_EQ(computeDotProduct(a, b), 32.F); + } + + TEST(DotProduct, functor) + { + const Vector<3, float> reference{ 1.F, 2.F, 3.F }; + const Vector<3, float> candidate{ 4.F, 5.F, 6.F }; + const DotProduct<3, float> dotProduct{ reference }; + + EXPECT_FLOAT_EQ(dotProduct(candidate), 32.F); + } +} // namespace lms::math::dotProductTests diff --git a/src/libs/math/test/Entropy.cpp b/src/libs/math/test/Entropy.cpp new file mode 100644 index 00000000..ed72d4fa --- /dev/null +++ b/src/libs/math/test/Entropy.cpp @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include + +#include "math/Entropy.hpp" + +namespace lms::math +{ + TEST(EntropyTest, ZeroInput) + { + std::array c{}; + + const float e{ entropy(c) }; + EXPECT_EQ(e, 0.f); + } + + TEST(EntropyTest, SingleBinIsZeroEntropy) + { + std::array c{}; + c[3] = 1.F; + + const float e{ entropy(c) }; + EXPECT_FLOAT_EQ(e, 0.F); + } + + TEST(EntropyTest, UniformDistributionMaxEntropy) + { + std::array c; + + for (auto& v : c) + v = 1.F; + + const float e{ entropy(c) }; + const float expected{ std::log(12.f) }; + EXPECT_FLOAT_EQ(e, expected); + } + + TEST(EntropyTest, ScaleInvariance) + { + std::array c{ 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f, 1.1f, 1.2f }; + + const float a{ entropy(c) }; + + for (auto& v : c) + v *= 1000.f; + + const float b{ entropy(c) }; + + EXPECT_FLOAT_EQ(a, b); + } + + TEST(EntropyTest, MoreSpreadMeansHigherEntropy) + { + std::array tight{}; + std::array spread{}; + + tight[5] = 0.5F; + tight[6] = 0.5F; + + spread[2] = 0.3F; + spread[6] = 0.4F; + spread[9] = 0.3F; + + EXPECT_GT(entropy(spread), entropy(tight)); + } +} // namespace lms::math \ No newline at end of file diff --git a/src/libs/math/test/EuclideanDistance.cpp b/src/libs/math/test/EuclideanDistance.cpp new file mode 100644 index 00000000..dae4ed71 --- /dev/null +++ b/src/libs/math/test/EuclideanDistance.cpp @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include + +#include "math/EuclideanDistance.hpp" + +namespace lms::math::euclideanDistanceTests +{ + constexpr float epsilon{ 1e-4F }; + + TEST(EuclideanDistance, zeroLength) + { + const Vector<0, float> a{}; + const Vector<0, float> b{}; + const Vector<0, float> weights{}; + + EXPECT_FLOAT_EQ(computeEuclideanSquaredDistance(a, b), 0.F); + EXPECT_FLOAT_EQ(computeEuclideanSquaredDistanceWithWeights(a, b, weights), 0.F); + } + + TEST(EuclideanDistance, equalVectors) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ 1.F, 2.F, 3.F }; + + EXPECT_FLOAT_EQ(computeEuclideanSquaredDistance(a, b), 0.F); + } + + TEST(EuclideanDistance, unweightedDistance) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ 4.F, 6.F, 8.F }; + + const float expected{ 50.F }; // 3^2 + 4^2 + 5^2 + EXPECT_NEAR(computeEuclideanSquaredDistance(a, b), expected, epsilon); + } + + TEST(EuclideanDistance, weightedDistance) + { + const Vector<3, float> a{ 1.F, 3.F, 5.F }; + const Vector<3, float> b{ 2.F, 1.F, 6.F }; + const Vector<3, float> weights{ 1.F, 0.5F, 2.F }; + + const float expected{ 5.F }; // 1*1 + 4*0.5 + 1*2 + EXPECT_NEAR(computeEuclideanSquaredDistanceWithWeights(a, b, weights), expected, epsilon); + } + + TEST(EuclideanDistance, largeMagnitudeValues) + { + // 1e15^2 * 2 = 2e30, well within the float max (~3.4e38), so no overflow + const float big{ 1e15F }; + const Vector<2, float> a{ big, big }; + const Vector<2, float> b{ 0.F, 0.F }; + + const float result{ computeEuclideanSquaredDistance(a, b) }; + EXPECT_GT(result, 0.F); + } + + TEST(EuclideanDistance, smallMagnitudeValues) + { + // Subnormal inputs; result must stay non-negative + const float tiny{ std::numeric_limits::min() }; + const Vector<3, float> a{ tiny, tiny, tiny }; + const Vector<3, float> b{ 0.F, 0.F, 0.F }; + + const float result{ computeEuclideanSquaredDistance(a, b) }; + EXPECT_GE(result, 0.F); + } + + TEST(EuclideanDistance, negativeValues) + { + // Negative components must produce the same result as their positive mirror + const Vector<3, float> a{ -1.F, -2.F, -3.F }; + const Vector<3, float> b{ 1.F, 2.F, 3.F }; + const Vector<3, float> aMirror{ 1.F, 2.F, 3.F }; + const Vector<3, float> bMirror{ -1.F, -2.F, -3.F }; + + EXPECT_FLOAT_EQ( + computeEuclideanSquaredDistance(a, b), + computeEuclideanSquaredDistance(aMirror, bMirror)); + } + + TEST(EuclideanDistance, zeroWeights) + { + const Vector<3, float> a{ 1.F, 2.F, 3.F }; + const Vector<3, float> b{ 4.F, 5.F, 6.F }; + const Vector<3, float> weights{ 0.F, 0.F, 0.F }; + + EXPECT_FLOAT_EQ(computeEuclideanSquaredDistanceWithWeights(a, b, weights), 0.F); + } + + TEST(EuclideanDistance, singleElement) + { + const Vector<1, float> a{ 3.F }; + const Vector<1, float> b{ 7.F }; + + EXPECT_FLOAT_EQ(computeEuclideanSquaredDistance(a, b), 16.F); + } +} // namespace lms::math::euclideanDistanceTests \ No newline at end of file diff --git a/src/libs/math/test/FFT.cpp b/src/libs/math/test/FFT.cpp new file mode 100644 index 00000000..cb94fadf --- /dev/null +++ b/src/libs/math/test/FFT.cpp @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include +#include +#include +#include +#include + +#include + +#include "core/AlignedHeapArray.hpp" + +#include "math/FFT.hpp" +#include "math/Window.hpp" + +namespace lms::math::fftTests +{ + constexpr float epsilon{ 1e-3F }; + + namespace + { + std::size_t getRealFFTOutputSize(std::size_t inputSize) + { + return inputSize / 2 + 1; + } + + std::vector> computeRealDFT(const std::vector& input) + { + const std::size_t N{ input.size() }; + std::vector> output(getRealFFTOutputSize(N)); + + for (std::size_t k{}; k <= N / 2; ++k) + { + std::complex sum{ 0.0, 0.0 }; + for (std::size_t n{}; n < N; ++n) + { + const double angle{ -2.0 * std::numbers::pi_v * static_cast(k) * static_cast(n) / static_cast(N) }; + std::complex w{ std::cos(angle), std::sin(angle) }; + sum += static_cast(input[n]) * w; + } + output[k] = { static_cast(sum.real()), static_cast(sum.imag()) }; + } + + return output; + } + } // namespace + + TEST(FFT, impulse) + { + constexpr std::size_t N{ 8 }; + const std::initializer_list inputSignal{ 1.F, 0.F, 0.F, 0.F, 0.F, 0.F, 0.F, 0.F }; + const auto expected{ computeRealDFT(inputSignal) }; + + FixedRealFFTPlan plan; + + core::AlignedHeapArray::minBufferAlignment> input{ N }; + core::AlignedHeapArray, FixedRealFFTPlan::minBufferAlignment> output{ getRealFFTOutputSize(N) }; + + std::copy(inputSignal.begin(), inputSignal.end(), input.begin()); + plan.apply(input, output); + for (std::size_t i{}; i < output.size(); ++i) + { + EXPECT_NEAR(output[i].real(), expected[i].real(), epsilon); + EXPECT_NEAR(output[i].imag(), expected[i].imag(), epsilon); + } + } + + TEST(FFT, realForwardMatchesReference) + { + constexpr std::size_t N{ 64 }; + + std::vector inputSignal(N); + for (std::size_t i{}; i < N; ++i) + { + inputSignal[i] = std::sin(2.F * std::numbers::pi_v * static_cast(i) / static_cast(N)) + + 0.25F * std::sin(6.F * std::numbers::pi_v * static_cast(i) / static_cast(N)); + } + + const auto expected{ computeRealDFT(inputSignal) }; + + FixedRealFFTPlan plan; + core::AlignedHeapArray::minBufferAlignment> input{ N }; + core::AlignedHeapArray, FixedRealFFTPlan::minBufferAlignment> output{ getRealFFTOutputSize(N) }; + std::copy(inputSignal.begin(), inputSignal.end(), input.begin()); + + plan.apply({ input.data(), input.size() }, { output.data(), output.size() }); + + for (std::size_t i{}; i < output.size(); ++i) + { + EXPECT_NEAR(output[i].real(), expected[i].real(), epsilon); + EXPECT_NEAR(output[i].imag(), expected[i].imag(), epsilon); + } + } + + TEST(FFT, singleFrequencyBin) + { + constexpr std::size_t N{ 64 }; + + for (std::size_t k{ 1 }; k < N / 2; ++k) + { + std::vector inputSignal(N); + for (std::size_t n{}; n < N; ++n) + inputSignal[n] = std::sin(2.F * std::numbers::pi_v * static_cast(k) * static_cast(n) / static_cast(N)); + + const auto expected{ computeRealDFT(inputSignal) }; + + FixedRealFFTPlan plan; + core::AlignedHeapArray::minBufferAlignment> input{ N }; + core::AlignedHeapArray, FixedRealFFTPlan::minBufferAlignment> output{ getRealFFTOutputSize(N) }; + std::copy(inputSignal.begin(), inputSignal.end(), input.begin()); + + plan.apply({ input.data(), input.size() }, { output.data(), output.size() }); + + for (std::size_t i{}; i < output.size(); ++i) + { + if (i == k) + EXPECT_GT(std::abs(output[i]), 10.F); + else + EXPECT_NEAR(std::abs(output[i]), std::abs(expected[i]), epsilon); + } + } + } + + TEST(FFT, forwardIsUnnormalized) + { + constexpr std::size_t N{ 64 }; + + FixedRealFFTPlan plan; + core::AlignedHeapArray::minBufferAlignment> input{ N }; + core::AlignedHeapArray, FixedRealFFTPlan::minBufferAlignment> output{ getRealFFTOutputSize(N) }; + std::fill(input.begin(), input.end(), 1.F); + + plan.apply({ input.data(), input.size() }, { output.data(), output.size() }); + + EXPECT_NEAR(output[0].real(), static_cast(N), epsilon); + } + + TEST(FFT, parseval) + { + constexpr std::size_t N{ 64 }; + + std::vector inputSignal(N); + for (std::size_t i{}; i < N; ++i) + inputSignal[i] = std::sin(static_cast(i)); + + float timeEnergy{}; + for (const auto value : inputSignal) + timeEnergy += value * value; + + FixedRealFFTPlan plan; + core::AlignedHeapArray::minBufferAlignment> input{ N }; + core::AlignedHeapArray, FixedRealFFTPlan::minBufferAlignment> output{ getRealFFTOutputSize(N) }; + std::copy(inputSignal.begin(), inputSignal.end(), input.begin()); + + plan.apply({ input.data(), input.size() }, { output.data(), output.size() }); + + float freqEnergy{}; + freqEnergy += std::norm(output[0]); + freqEnergy += std::norm(output[N / 2]); + for (std::size_t k{ 1 }; k < N / 2; ++k) + freqEnergy += 2.F * std::norm(output[k]); + + EXPECT_NEAR(timeEnergy, freqEnergy / static_cast(N), epsilon); + } + + TEST(FFT, parsevalWithWindow) + { + constexpr std::size_t N{ 64 }; + + std::vector inputSignal(N); + for (std::size_t n{}; n < N; ++n) + inputSignal[n] = std::sin(2.F * std::numbers::pi_v * static_cast(n) / static_cast(N)); + + const math::HannWindow window; + const float windowEnergy{ window.energy() }; + + std::vector windowedInput(N); + window.apply(std::span{ inputSignal.data(), inputSignal.size() }, + std::span{ windowedInput.data(), windowedInput.size() }); + + float E_time{}; + for (float x : windowedInput) + E_time += x * x; + E_time /= windowEnergy; + + FixedRealFFTPlan plan; + core::AlignedHeapArray::minBufferAlignment> input{ N }; + core::AlignedHeapArray, FixedRealFFTPlan::minBufferAlignment> output{ getRealFFTOutputSize(N) }; + std::copy(windowedInput.begin(), windowedInput.end(), input.begin()); + plan.apply({ input.data(), input.size() }, { output.data(), output.size() }); + + float E_freq{}; + E_freq += std::norm(output[0]); + E_freq += std::norm(output[N / 2]); + for (std::size_t k{ 1 }; k < N / 2; ++k) + E_freq += 2.F * std::norm(output[k]); + E_freq /= (windowEnergy * static_cast(N)); + + EXPECT_NEAR(E_time, E_freq, epsilon * E_time) << "Time-domain and frequency-domain energy mismatch after windowing"; + } +} // namespace lms::math::fftTests diff --git a/src/libs/math/test/MedoidCalculator.cpp b/src/libs/math/test/MedoidCalculator.cpp new file mode 100644 index 00000000..b1082979 --- /dev/null +++ b/src/libs/math/test/MedoidCalculator.cpp @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "math/MedoidCalculator.hpp" +#include "math/Vector.hpp" + +namespace lms::math::medoidCalculatorTests +{ + TEST(MedoidCalculator, initialState) + { + MedoidCalculator> calculator; + + EXPECT_TRUE(calculator.empty()); + EXPECT_EQ(calculator.count(), 0U); + } + + TEST(MedoidCalculator, singleVector) + { + MedoidCalculator> calculator; + const Vector<3, float> vec{ 1.0F, 2.0F, 3.0F }; + calculator.add(vec); + + EXPECT_FALSE(calculator.empty()); + EXPECT_EQ(calculator.count(), 1U); + EXPECT_EQ(calculator.findMedoidIndex(), 0U); + + const Vector<3, float> result = calculator.finalize(); + EXPECT_EQ(result[0], 1.0F); + EXPECT_EQ(result[1], 2.0F); + EXPECT_EQ(result[2], 3.0F); + } + + TEST(MedoidCalculator, twoVectors) + { + MedoidCalculator> calculator; + const Vector<2, float> v1{ 0.0F, 0.0F }; + const Vector<2, float> v2{ 4.0F, 0.0F }; + + calculator.add(v1); + calculator.add(v2); + + EXPECT_EQ(calculator.count(), 2U); + // Both have equal distance to the other, but first one is returned + const std::size_t medoidIndex = calculator.findMedoidIndex(); + EXPECT_TRUE(medoidIndex == 0 || medoidIndex == 1); + } + + TEST(MedoidCalculator, threeDifferentVectors) + { + MedoidCalculator> calculator; + // Three points: (0,0), (1,0), (10,0) + // Medoid should be (1,0) as it's closest to the others + calculator.add(Vector<2, float>{ 0.0F, 0.0F }); + calculator.add(Vector<2, float>{ 1.0F, 0.0F }); + calculator.add(Vector<2, float>{ 10.0F, 0.0F }); + + const std::size_t medoidIndex = calculator.findMedoidIndex(); + EXPECT_EQ(medoidIndex, 1U); // The middle point (1,0) is the medoid + + const Vector<2, float> result = calculator.finalize(); + EXPECT_FLOAT_EQ(result[0], 1.0F); + EXPECT_FLOAT_EQ(result[1], 0.0F); + } + + TEST(MedoidCalculator, computeMedoidSpan) + { + const std::array, 3> values{ + Vector<2, float>{ 0.0F, 0.0F }, + Vector<2, float>{ 1.0F, 0.0F }, + Vector<2, float>{ 10.0F, 0.0F } + }; + + const Vector<2, float> result = computeMedoid(std::span>(values)); + + EXPECT_FLOAT_EQ(result[0], 1.0F); + EXPECT_FLOAT_EQ(result[1], 0.0F); + } + + TEST(MedoidCalculator, getVector) + { + MedoidCalculator> calculator; + calculator.add(Vector<2, float>{ 1.0F, 2.0F }); + calculator.add(Vector<2, float>{ 3.0F, 4.0F }); + + const Vector<2, float>& v0 = calculator.getVector(0U); + const Vector<2, float>& v1 = calculator.getVector(1U); + + EXPECT_FLOAT_EQ(v0[0], 1.0F); + EXPECT_FLOAT_EQ(v0[1], 2.0F); + EXPECT_FLOAT_EQ(v1[0], 3.0F); + EXPECT_FLOAT_EQ(v1[1], 4.0F); + } + + TEST(MedoidCalculator, clear) + { + MedoidCalculator> calculator; + calculator.add(Vector<2, float>{ 1.0F, 2.0F }); + EXPECT_EQ(calculator.count(), 1); + calculator.clear(); + EXPECT_EQ(calculator.count(), 0); + } +} // namespace lms::math::medoidCalculatorTests diff --git a/src/libs/math/test/NormalizedCosineDistance.cpp b/src/libs/math/test/NormalizedCosineDistance.cpp new file mode 100644 index 00000000..112c6a18 --- /dev/null +++ b/src/libs/math/test/NormalizedCosineDistance.cpp @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "math/NormalizedCosineDistance.hpp" + +namespace lms::math::normalizedCosineDistanceTests +{ + constexpr float epsilon{ 1e-6F }; + + TEST(NormalizedCosineDistance, equalNormalizedVectors) + { + Vector<3, float> a{ 1.F, 2.F, 3.F }; + Vector<3, float> b{ 1.F, 2.F, 3.F }; + + a.normalizeL2(); + b.normalizeL2(); + + EXPECT_NEAR(computeNormalizedCosineDistance(a, b), 0.F, epsilon); + } + + TEST(NormalizedCosineDistance, orthogonalNormalizedVectors) + { + Vector<3, float> a{ 1.F, 0.F, 0.F }; + Vector<3, float> b{ 0.F, 1.F, 0.F }; + + a.normalizeL2(); + b.normalizeL2(); + + EXPECT_NEAR(computeNormalizedCosineDistance(a, b), 0.5F, epsilon); + } + + TEST(NormalizedCosineDistance, oppositeNormalizedVectors) + { + Vector<3, float> a{ 1.F, 1.F, 0.F }; + Vector<3, float> b{ -1.F, -1.F, 0.F }; + + a.normalizeL2(); + b.normalizeL2(); + + EXPECT_NEAR(computeNormalizedCosineDistance(a, b), 1.F, epsilon); + } + + TEST(NormalizedCosineDistance, functor) + { + Vector<3, float> reference{ 1.F, 0.F, 0.F }; + Vector<3, float> candidate{ 0.F, 1.F, 0.F }; + + reference.normalizeL2(); + candidate.normalizeL2(); + + const NormalizedCosineDistance<3, float> distance{ reference }; + + EXPECT_NEAR(distance(candidate), 0.5F, epsilon); + } +} // namespace lms::math::normalizedCosineDistanceTests diff --git a/src/libs/math/test/PrincipalComponents.cpp b/src/libs/math/test/PrincipalComponents.cpp new file mode 100644 index 00000000..f24d073e --- /dev/null +++ b/src/libs/math/test/PrincipalComponents.cpp @@ -0,0 +1,282 @@ +/* + * 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 . + */ + +#include + +#include "math/PrincipalComponents.hpp" + +namespace lms::math::principalComponentsTests +{ + constexpr float epsilon{ 1e-4F }; + constexpr float doubleEpsilon{ 1e-8 }; + + TEST(PrincipalComponents, dotProductZeroVectors) + { + Vector<3, float> a{ 0.0F, 0.0F, 0.0F }; + Vector<3, float> b{ 1.0F, 2.0F, 3.0F }; + + EXPECT_FLOAT_EQ(dotProduct(a, b), 0.0F); + } + + TEST(PrincipalComponents, dotProductOrthogonal) + { + Vector<3, float> a{ 1.0F, 0.0F, 0.0F }; + Vector<3, float> b{ 0.0F, 1.0F, 0.0F }; + + EXPECT_FLOAT_EQ(dotProduct(a, b), 0.0F); + } + + TEST(PrincipalComponents, dotProductParallel) + { + Vector<3, float> a{ 1.0F, 2.0F, 3.0F }; + Vector<3, float> b{ 2.0F, 4.0F, 6.0F }; + + EXPECT_FLOAT_EQ(dotProduct(a, b), 28.0F); // 2 + 8 + 18 + } + + TEST(PrincipalComponents, dotProductAntiparallel) + { + Vector<3, float> a{ 1.0F, 2.0F, 3.0F }; + Vector<3, float> b{ -1.0F, -2.0F, -3.0F }; + + EXPECT_FLOAT_EQ(dotProduct(a, b), -14.0F); + } + + TEST(PrincipalComponents, dotProductDouble) + { + Vector<3, double> a{ 0.5, 0.5, 0.5 }; + Vector<3, double> b{ 2.0, 2.0, 2.0 }; + + EXPECT_DOUBLE_EQ(dotProduct(a, b), 3.0); + } + + TEST(PrincipalComponents, pearsonCorrelationIdentical) + { + Vector<5, float> a{ 1.0F, 2.0F, 3.0F, 4.0F, 5.0F }; + Vector<5, float> b{ 1.0F, 2.0F, 3.0F, 4.0F, 5.0F }; + + EXPECT_NEAR(pearsonCorrelation(a, b), 1.0F, epsilon); + } + + TEST(PrincipalComponents, pearsonCorrelationNegative) + { + Vector<5, float> a{ 1.0F, 2.0F, 3.0F, 4.0F, 5.0F }; + Vector<5, float> b{ 5.0F, 4.0F, 3.0F, 2.0F, 1.0F }; + + EXPECT_NEAR(pearsonCorrelation(a, b), -1.0F, epsilon); + } + + TEST(PrincipalComponents, pearsonCorrelationIndependent) + { + Vector<4, float> a{ 1.0F, 2.0F, 3.0F, 4.0F }; + Vector<4, float> b{ 4.0F, 3.0F, 2.0F, 1.0F }; + + EXPECT_NEAR(std::abs(pearsonCorrelation(a, b)), 1.0F, epsilon); + } + + TEST(PrincipalComponents, pearsonCorrelationConstantVector) + { + Vector<5, float> a{ 1.0F, 2.0F, 3.0F, 4.0F, 5.0F }; + Vector<5, float> b{ 2.0F, 2.0F, 2.0F, 2.0F, 2.0F }; + + // Constant vector has zero variance + EXPECT_FLOAT_EQ(pearsonCorrelation(a, b), 0.0F); + } + + TEST(PrincipalComponents, pearsonCorrelationBothConstant) + { + Vector<5, float> a{ 1.0F, 1.0F, 1.0F, 1.0F, 1.0F }; + Vector<5, float> b{ 2.0F, 2.0F, 2.0F, 2.0F, 2.0F }; + + EXPECT_FLOAT_EQ(pearsonCorrelation(a, b), 0.0F); + } + + TEST(PrincipalComponents, pearsonCorrelationWeakPositive) + { + Vector<4, float> a{ 1.0F, 2.0F, 3.0F, 4.0F }; + Vector<4, float> b{ 1.1F, 2.1F, 2.9F, 3.9F }; + + float corr = pearsonCorrelation(a, b); + EXPECT_GT(corr, 0.9F); + EXPECT_LE(corr, 1.0F); + } + + TEST(PrincipalComponents, powerIterationReturnsEigenpairs) + { + SquareMatrix covariance; + covariance[0][0] = 2.0; + covariance[0][1] = 0.0; + covariance[1][0] = 0.0; + covariance[1][1] = 1.0; + + Vector<2, double> eigenvalues{}; + std::array, 2> eigenvectors; + + computeEigenpairsViaPowerIteration(covariance, eigenvectors, eigenvalues); + + EXPECT_NEAR(eigenvalues[0], 2.0, epsilon); + EXPECT_NEAR(eigenvalues[1], 1.0, epsilon); + + for (std::size_t k{}; k < 2; ++k) + { + Vector<2, double> Av{}; + for (std::size_t i{}; i < 2; ++i) + { + for (std::size_t j{}; j < 2; ++j) + Av[i] += covariance[i][j] * eigenvectors[k][j]; + } + + EXPECT_NEAR(Av[0], eigenvalues[k] * eigenvectors[k][0], epsilon); + EXPECT_NEAR(Av[1], eigenvalues[k] * eigenvectors[k][1], epsilon); + } + } + + TEST(PrincipalComponents, powerIterationIdentity) + { + SquareMatrix covariance; + covariance.fill(0.0); + covariance[0][0] = 1.0; + covariance[1][1] = 1.0; + covariance[2][2] = 1.0; + + Vector<3, double> eigenvalues{}; + std::array, 3> eigenvectors; + + computeEigenpairsViaPowerIteration(covariance, eigenvectors, eigenvalues); + + // All eigenvalues should be 1 + EXPECT_NEAR(eigenvalues[0], 1.0, doubleEpsilon); + EXPECT_NEAR(eigenvalues[1], 1.0, doubleEpsilon); + EXPECT_NEAR(eigenvalues[2], 1.0, doubleEpsilon); + } + + TEST(PrincipalComponents, powerIterationSymmetric) + { + SquareMatrix covariance; + covariance[0][0] = 4.0; + covariance[0][1] = 2.0; + covariance[1][0] = 2.0; + covariance[1][1] = 3.0; + + Vector<2, double> eigenvalues{}; + std::array, 2> eigenvectors; + + computeEigenpairsViaPowerIteration(covariance, eigenvectors, eigenvalues); + + // Verify A*v = lambda*v for each eigenpair + for (std::size_t k{}; k < 2; ++k) + { + Vector<2, double> Av{}; + for (std::size_t i{}; i < 2; ++i) + { + for (std::size_t j{}; j < 2; ++j) + Av[i] += covariance[i][j] * eigenvectors[k][j]; + } + + EXPECT_NEAR(Av[0], eigenvalues[k] * eigenvectors[k][0], epsilon); + EXPECT_NEAR(Av[1], eigenvalues[k] * eigenvectors[k][1], epsilon); + } + } + + TEST(PrincipalComponents, powerIterationWithCustomIterations) + { + SquareMatrix covariance; + covariance[0][0] = 2.0; + covariance[0][1] = 0.0; + covariance[1][0] = 0.0; + covariance[1][1] = 1.0; + + Vector<2, double> eigenvalues{}; + std::array, 2> eigenvectors; + + // Use only 50 iterations + computeEigenpairsViaPowerIteration(covariance, eigenvectors, eigenvalues, 50, 1e-10); + + EXPECT_NEAR(eigenvalues[0], 2.0, 1e-2); + EXPECT_NEAR(eigenvalues[1], 1.0, 1e-2); + } + + TEST(PrincipalComponents, powerIterationWithCustomEpsilon) + { + SquareMatrix covariance; + covariance[0][0] = 2.0; + covariance[0][1] = 0.0; + covariance[1][0] = 0.0; + covariance[1][1] = 1.0; + + Vector<2, double> eigenvalues{}; + std::array, 2> eigenvectors; + + // Use looser epsilon + computeEigenpairsViaPowerIteration(covariance, eigenvectors, eigenvalues, 200, 1e-6); + + EXPECT_NEAR(eigenvalues[0], 2.0, epsilon); + EXPECT_NEAR(eigenvalues[1], 1.0, epsilon); + } + + TEST(PrincipalComponents, powerIterationLargerMatrix) + { + // Create a 4x4 diagonal matrix + SquareMatrix covariance; + covariance.fill(0.0); + covariance[0][0] = 4.0; + covariance[1][1] = 3.0; + covariance[2][2] = 2.0; + covariance[3][3] = 1.0; + + Vector<4, double> eigenvalues{}; + std::array, 4> eigenvectors; + + computeEigenpairsViaPowerIteration(covariance, eigenvectors, eigenvalues); + + // Eigenvalues should be 4, 3, 2, 1 (in descending order after deflation) + EXPECT_NEAR(eigenvalues[0], 4.0, doubleEpsilon); + EXPECT_NEAR(eigenvalues[1], 3.0, doubleEpsilon); + EXPECT_NEAR(eigenvalues[2], 2.0, doubleEpsilon); + EXPECT_NEAR(eigenvalues[3], 1.0, doubleEpsilon); + } + + TEST(PrincipalComponents, projectOntoBasis) + { + std::array, 2> basis{}; + basis[0][0] = 1.0F; + basis[0][1] = 0.0F; + basis[1][0] = 0.0F; + basis[1][1] = 1.0F; + + Vector<2, float> centered{ 1.0F, 2.0F }; + Vector<2, float> output; + std::array scales{ 2.0F, 3.0F }; + + projectOntoBasis(basis, centered, output, scales); + + EXPECT_FLOAT_EQ(output[0], 2.0F); + EXPECT_FLOAT_EQ(output[1], 6.0F); + } + + TEST(PrincipalComponents, pearsonCorrelation) + { + Vector<3, float> a{ 1.0F, 2.0F, 3.0F }; + Vector<3, float> b{ 1.0F, 2.0F, 3.0F }; + Vector<3, float> c{ -1.0F, -2.0F, -3.0F }; + + EXPECT_NEAR(pearsonCorrelation(a, b), 1.0F, epsilon); + EXPECT_NEAR(pearsonCorrelation(a, c), -1.0F, epsilon); + } +} // namespace lms::math::principalComponentsTests diff --git a/src/libs/math/test/SquareMatrix.cpp b/src/libs/math/test/SquareMatrix.cpp new file mode 100644 index 00000000..d0a136b2 --- /dev/null +++ b/src/libs/math/test/SquareMatrix.cpp @@ -0,0 +1,161 @@ +/* + * 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 . + */ + +#include + +#include "math/SquareMatrix.hpp" + +namespace lms::math::squareMatrixTests +{ + constexpr float epsilon{ 1e-4F }; + + TEST(SquareMatrix, choleskyDecomposePositiveDefinite) + { + SquareMatrix matrix; + matrix.fill(0.F); + + matrix[0][0] = 4.F; + matrix[0][1] = 12.F; + matrix[0][2] = -16.F; + matrix[1][0] = 12.F; + matrix[1][1] = 37.F; + matrix[1][2] = -43.F; + matrix[2][0] = -16.F; + matrix[2][1] = -43.F; + matrix[2][2] = 98.F; + + SquareMatrix lower; + EXPECT_TRUE(choleskyDecompose(matrix, lower)); + + EXPECT_FLOAT_EQ(lower[0][0], 2.F); + EXPECT_FLOAT_EQ(lower[1][0], 6.F); + EXPECT_FLOAT_EQ(lower[1][1], 1.F); + EXPECT_FLOAT_EQ(lower[2][0], -8.F); + EXPECT_FLOAT_EQ(lower[2][1], 5.F); + EXPECT_FLOAT_EQ(lower[2][2], 3.F); + } + + TEST(SquareMatrix, choleskyDecomposeIdentity) + { + SquareMatrix matrix; + matrix.fill(0.F); + + matrix[0][0] = 1.F; + matrix[1][1] = 1.F; + matrix[2][2] = 1.F; + + SquareMatrix lower; + EXPECT_TRUE(choleskyDecompose(matrix, lower)); + + for (std::size_t i{}; i < 3; ++i) + { + for (std::size_t j{}; j < 3; ++j) + { + if (i == j) + EXPECT_FLOAT_EQ(lower[i][j], 1.F); + else + EXPECT_FLOAT_EQ(lower[i][j], 0.F); + } + } + } + + TEST(SquareMatrix, choleskyDecomposeSize1) + { + SquareMatrix matrix; + matrix[0][0] = 9.F; + + SquareMatrix lower; + EXPECT_TRUE(choleskyDecompose(matrix, lower)); + EXPECT_FLOAT_EQ(lower[0][0], 3.F); + } + + TEST(SquareMatrix, choleskyDecomposeNonPositiveDefinite) + { + SquareMatrix matrix; + matrix.fill(0.F); + + SquareMatrix lower; + EXPECT_FALSE(choleskyDecompose(matrix, lower)); + } + + TEST(SquareMatrix, invertLowerTriangular) + { + SquareMatrix lower; + lower.fill(0.F); + + lower[0][0] = 2.F; + lower[1][0] = 6.F; + lower[1][1] = 1.F; + lower[2][0] = -8.F; + lower[2][1] = 5.F; + lower[2][2] = 3.F; + + SquareMatrix inverse; + invertLowerTriangular(lower, inverse); + + SquareMatrix identity; + identity.fill(0.F); + + for (std::size_t i{}; i < 3; ++i) + { + for (std::size_t j{}; j < 3; ++j) + { + float sum = 0.F; + for (std::size_t k{}; k < 3; ++k) + { + sum += lower[i][k] * inverse[k][j]; + } + identity[i][j] = sum; + } + } + + for (std::size_t i{}; i < 3; ++i) + { + for (std::size_t j{}; j < 3; ++j) + { + if (i == j) + EXPECT_NEAR(identity[i][j], 1.F, epsilon); + else + EXPECT_NEAR(identity[i][j], 0.F, epsilon); + } + } + } + + TEST(SquareMatrix, invertLowerTriangularSize1) + { + SquareMatrix lower; + lower[0][0] = 5.F; + + SquareMatrix inverse; + invertLowerTriangular(lower, inverse); + + EXPECT_FLOAT_EQ(inverse[0][0], 0.2F); + } + + TEST(SquareMatrix, computeSymmetryMaxDiff) + { + SquareMatrix matrix; + matrix.fill(0.F); + + matrix[0][1] = 1.F; + matrix[1][0] = 1.2F; + + EXPECT_NEAR(computeSymmetryMaxDiff(matrix), 0.2F, epsilon); + } +} // namespace lms::math::squareMatrixTests diff --git a/src/libs/math/test/StatsAccumulator.cpp b/src/libs/math/test/StatsAccumulator.cpp new file mode 100644 index 00000000..2586a3fb --- /dev/null +++ b/src/libs/math/test/StatsAccumulator.cpp @@ -0,0 +1,172 @@ +/* + * 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 . + */ + +#include + +#include "math/StatsAccumulator.hpp" + +namespace lms::math::statsAccumulatorTests +{ + constexpr float epsilon{ 1e-4F }; + + TEST(StatsAccumulator, initialState) + { + StatsAccumulator stats; + + EXPECT_EQ(stats.getCount(), 0); + EXPECT_FLOAT_EQ(stats.getMean(), 0.F); + EXPECT_FLOAT_EQ(stats.getPopulationVariance(), 0.F); + EXPECT_FLOAT_EQ(stats.getSampleVariance(), 0.F); + EXPECT_FLOAT_EQ(stats.getPopulationStdDev(), 0.F); + } + + TEST(StatsAccumulator, singleValue) + { + constexpr float value{ 5.F }; + StatsAccumulator stats; + stats.add(value); + + EXPECT_EQ(stats.getCount(), 1); + EXPECT_FLOAT_EQ(stats.getMean(), value); + + // Variance should be 0 for a single value + EXPECT_FLOAT_EQ(stats.getPopulationVariance(), 0.F); + EXPECT_FLOAT_EQ(stats.getSampleVariance(), 0.F); + } + + TEST(StatsAccumulator, multipleValuesMean) + { + constexpr float a{ 2.F }; + constexpr float b{ 4.F }; + constexpr float c{ 6.F }; + StatsAccumulator stats; + stats.add(a); + stats.add(b); + stats.add(c); + + EXPECT_EQ(stats.getCount(), 3); + EXPECT_FLOAT_EQ(stats.getMean(), b); + } + + TEST(StatsAccumulator, populationVariance) + { + constexpr float a{ 2.F }; + constexpr float b{ 4.F }; + constexpr float c{ 6.F }; + constexpr float expectedVariance{ 8.F / 3.F }; + StatsAccumulator stats; + stats.add(a); + stats.add(b); + stats.add(c); + + // Population variance = 8 / 3 ≈ 2.6667 + EXPECT_NEAR(stats.getPopulationVariance(), expectedVariance, epsilon); + } + + TEST(StatsAccumulator, sampleVariance) + { + constexpr float a{ 2.F }; + constexpr float b{ 4.F }; + constexpr float c{ 6.F }; + constexpr float expectedVariance{ 4.F }; + StatsAccumulator stats; + stats.add(a); + stats.add(b); + stats.add(c); + + // Sample variance = 8 / 2 = 4 + EXPECT_NEAR(stats.getSampleVariance(), expectedVariance, epsilon); + } + + TEST(StatsAccumulator, standardDeviation) + { + constexpr float a{ 2.F }; + constexpr float b{ 4.F }; + constexpr float c{ 6.F }; + constexpr float expectedStdDev{ 2.F }; + StatsAccumulator stats; + stats.add(a); + stats.add(b); + stats.add(c); + + // sqrt(4) = 2 (sample stddev) + EXPECT_NEAR(stats.getSampleStdDev(), expectedStdDev, epsilon); + } + + TEST(StatsAccumulator, largeMagnitudeValues) + { + // Welford's algorithm must stay numerically stable with large inputs + // 1e6 is within float's ~7 significant-digit range + constexpr float big{ 1e6F }; + constexpr float offset{ 2.F }; + StatsAccumulator stats; + stats.add(big); + stats.add(big + 1.F); + stats.add(big + offset); + + EXPECT_NEAR(stats.getMean(), big + 1.F, 1e-1F); + EXPECT_NEAR(stats.getSampleVariance(), 1.F, 1e-1F); + EXPECT_NEAR(stats.getSampleStdDev(), 1.F, 1e-1F); + } + + TEST(StatsAccumulator, negativeValues) + { + constexpr float a{ -6.F }; + constexpr float b{ -4.F }; + constexpr float c{ -2.F }; + constexpr float expectedVariance{ 4.F }; + StatsAccumulator stats; + stats.add(a); + stats.add(b); + stats.add(c); + + EXPECT_NEAR(stats.getMean(), b, epsilon); + EXPECT_NEAR(stats.getSampleVariance(), expectedVariance, epsilon); + } + + TEST(StatsAccumulator, mixedSignValues) + { + constexpr float a{ -1.F }; + constexpr float b{ 0.F }; + constexpr float c{ 1.F }; + constexpr float expectedVariance{ 1.F }; + StatsAccumulator stats; + stats.add(a); + stats.add(b); + stats.add(c); + + EXPECT_NEAR(stats.getMean(), 0.F, epsilon); + EXPECT_NEAR(stats.getSampleVariance(), expectedVariance, epsilon); + } + + TEST(StatsAccumulator, smallMagnitudeValues) + { + // Values well within float's normal range; variance must stay non-negative + constexpr float tiny{ 1e-30F }; + constexpr float multiplier2{ 2.F }; + constexpr float multiplier3{ 3.F }; + StatsAccumulator stats; + stats.add(tiny); + stats.add(tiny * multiplier2); + stats.add(tiny * multiplier3); + + EXPECT_GE(stats.getSampleVariance(), 0.F); + EXPECT_GE(stats.getSampleStdDev(), 0.F); + } +} // namespace lms::math::statsAccumulatorTests \ No newline at end of file diff --git a/src/libs/math/test/Vector.cpp b/src/libs/math/test/Vector.cpp new file mode 100644 index 00000000..f75d30b0 --- /dev/null +++ b/src/libs/math/test/Vector.cpp @@ -0,0 +1,278 @@ +/* + * 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 . + */ + +#include + +#include "math/Vector.hpp" + +namespace lms::math::vectorTests +{ + constexpr float epsilon{ 1e-5F }; + + TEST(Vector, constructionDefault) + { + Vector<3, float> v; + + EXPECT_FLOAT_EQ(v[0], 0.F); + EXPECT_FLOAT_EQ(v[1], 0.F); + EXPECT_FLOAT_EQ(v[2], 0.F); + } + + TEST(Vector, constructionWithInitValue) + { + Vector<3, float> v{ 5.0F }; + + EXPECT_FLOAT_EQ(v[0], 5.0F); + EXPECT_FLOAT_EQ(v[1], 5.0F); + EXPECT_FLOAT_EQ(v[2], 5.0F); + } + + TEST(Vector, constructionWithArgs) + { + Vector<3, float> v{ 1.0F, 2.0F, 3.0F }; + + EXPECT_FLOAT_EQ(v[0], 1.0F); + EXPECT_FLOAT_EQ(v[1], 2.0F); + EXPECT_FLOAT_EQ(v[2], 3.0F); + } + + TEST(Vector, size) + { + Vector<3, float> v; + EXPECT_EQ(v.getSize(), 3U); + } + + TEST(Vector, dataAccess) + { + Vector<3, float> v{ 1.0F, 2.0F, 3.0F }; + + const float* data = v.data(); + EXPECT_FLOAT_EQ(data[0], 1.0F); + EXPECT_FLOAT_EQ(data[1], 2.0F); + EXPECT_FLOAT_EQ(data[2], 3.0F); + } + + TEST(Vector, operatorAddAssign) + { + Vector<3, float> a{ 1.0F, 2.0F, 3.0F }; + Vector<3, float> b{ 4.0F, 5.0F, 6.0F }; + + a += b; + + EXPECT_FLOAT_EQ(a[0], 5.0F); + EXPECT_FLOAT_EQ(a[1], 7.0F); + EXPECT_FLOAT_EQ(a[2], 9.0F); + } + + TEST(Vector, operatorSubAssign) + { + Vector<3, float> a{ 4.0F, 5.0F, 6.0F }; + Vector<3, float> b{ 1.0F, 2.0F, 3.0F }; + + a -= b; + + EXPECT_FLOAT_EQ(a[0], 3.0F); + EXPECT_FLOAT_EQ(a[1], 3.0F); + EXPECT_FLOAT_EQ(a[2], 3.0F); + } + + TEST(Vector, operatorMulAssign) + { + Vector<3, float> v{ 1.0F, 2.0F, 3.0F }; + + v *= 2.0F; + + EXPECT_FLOAT_EQ(v[0], 2.0F); + EXPECT_FLOAT_EQ(v[1], 4.0F); + EXPECT_FLOAT_EQ(v[2], 6.0F); + } + + TEST(Vector, operatorAdd) + { + Vector<3, float> a{ 1.0F, 2.0F, 3.0F }; + Vector<3, float> b{ 4.0F, 5.0F, 6.0F }; + + Vector<3, float> result = a + b; + + EXPECT_FLOAT_EQ(result[0], 5.0F); + EXPECT_FLOAT_EQ(result[1], 7.0F); + EXPECT_FLOAT_EQ(result[2], 9.0F); + + // Ensure originals unchanged + EXPECT_FLOAT_EQ(a[0], 1.0F); + EXPECT_FLOAT_EQ(b[0], 4.0F); + } + + TEST(Vector, operatorSub) + { + Vector<3, float> a{ 4.0F, 5.0F, 6.0F }; + Vector<3, float> b{ 1.0F, 2.0F, 3.0F }; + + Vector<3, float> result = a - b; + + EXPECT_FLOAT_EQ(result[0], 3.0F); + EXPECT_FLOAT_EQ(result[1], 3.0F); + EXPECT_FLOAT_EQ(result[2], 3.0F); + + // Ensure originals unchanged + EXPECT_FLOAT_EQ(a[0], 4.0F); + } + + TEST(Vector, operatorMulScalarRight) + { + Vector<3, float> v{ 1.0F, 2.0F, 3.0F }; + + Vector<3, float> result = v * 2.0F; + + EXPECT_FLOAT_EQ(result[0], 2.0F); + EXPECT_FLOAT_EQ(result[1], 4.0F); + EXPECT_FLOAT_EQ(result[2], 6.0F); + + // Ensure original unchanged + EXPECT_FLOAT_EQ(v[0], 1.0F); + } + + TEST(Vector, operatorMulScalarLeft) + { + Vector<3, float> v{ 1.0F, 2.0F, 3.0F }; + + Vector<3, float> result = 3.0F * v; + + EXPECT_FLOAT_EQ(result[0], 3.0F); + EXPECT_FLOAT_EQ(result[1], 6.0F); + EXPECT_FLOAT_EQ(result[2], 9.0F); + } + + TEST(Vector, computeNorm) + { + Vector<3, float> v{ 3.0F, 4.0F, 0.0F }; + + EXPECT_FLOAT_EQ(v.computeNorm(), 5.0F); + } + + TEST(Vector, computeNormZero) + { + Vector<3, float> v{ 0.0F, 0.0F, 0.0F }; + + EXPECT_FLOAT_EQ(v.computeNorm(), 0.0F); + } + + TEST(Vector, normalizeL2) + { + Vector<3, float> v{ 3.0F, 4.0F, 0.0F }; + + v.normalizeL2(); + + EXPECT_NEAR(v.computeNorm(), 1.0F, epsilon); + EXPECT_NEAR(v[0], 0.6F, epsilon); + EXPECT_NEAR(v[1], 0.8F, epsilon); + EXPECT_NEAR(v[2], 0.0F, epsilon); + } + + TEST(Vector, normalizeL2ZeroVector) + { + Vector<3, float> v{ 0.0F, 0.0F, 0.0F }; + + v.normalizeL2(); + + // Zero vector remains unchanged + EXPECT_FLOAT_EQ(v[0], 0.0F); + EXPECT_FLOAT_EQ(v[1], 0.0F); + EXPECT_FLOAT_EQ(v[2], 0.0F); + } + + TEST(Vector, normalizeL2SmallVector) + { + constexpr float tiny{ 1e-15F }; + Vector<3, float> v{ tiny, tiny, tiny }; + + v.normalizeL2(); + + // Small vector remains unchanged due to epsilon check + EXPECT_FLOAT_EQ(v[0], tiny); + EXPECT_FLOAT_EQ(v[1], tiny); + EXPECT_FLOAT_EQ(v[2], tiny); + } + + TEST(Vector, iterators) + { + Vector<3, float> v{ 1.0F, 2.0F, 3.0F }; + + std::size_t index{}; + for (float val : v) + { + EXPECT_FLOAT_EQ(val, static_cast(index + 1)); + ++index; + } + } + + TEST(Vector, constIterators) + { + const Vector<3, float> v{ 1.0F, 2.0F, 3.0F }; + + std::size_t index{}; + for (auto it = v.cbegin(); it != v.cend(); ++it) + { + EXPECT_FLOAT_EQ(*it, static_cast(index + 1)); + ++index; + } + } + + TEST(Vector, size1) + { + Vector<1, float> v{ 5.0F }; + + EXPECT_FLOAT_EQ(v[0], 5.0F); + EXPECT_FLOAT_EQ(v.computeNorm(), 5.0F); + } + + TEST(Vector, largeSize) + { + constexpr std::size_t size{ 1000 }; + Vector v{ 1.0F }; + + EXPECT_NEAR(v.computeNorm(), std::sqrt(static_cast(size)), 1e-4F); + } + + TEST(Vector, negativeValues) + { + Vector<3, float> v{ -1.0F, -2.0F, -3.0F }; + + EXPECT_FLOAT_EQ(v.computeNorm(), std::sqrt(14.0F)); + } + + TEST(Vector, mixedSignValues) + { + Vector<3, float> a{ -1.0F, 2.0F, -3.0F }; + Vector<3, float> b{ 1.0F, -2.0F, 3.0F }; + const Vector<3, float> sum{ a + b }; + + EXPECT_FLOAT_EQ(sum[0], 0.0F); + EXPECT_FLOAT_EQ(sum[1], 0.0F); + EXPECT_FLOAT_EQ(sum[2], 0.0F); + } + + TEST(Vector, doubleType) + { + Vector<3, double> v{ 1.0, 2.0, 3.0 }; + + EXPECT_DOUBLE_EQ(v[0], 1.0); + EXPECT_NEAR(v.computeNorm(), std::sqrt(14.0), 1e-15); + } +} // namespace lms::math::vectorTests diff --git a/src/libs/math/test/Window.cpp b/src/libs/math/test/Window.cpp new file mode 100644 index 00000000..2565128e --- /dev/null +++ b/src/libs/math/test/Window.cpp @@ -0,0 +1,90 @@ +/* + * 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 . + */ + +#include +#include + +#include + +#include "math/Window.hpp" + +namespace lms::math::tests +{ + TEST(Window, oneSampleWindowIsFinite) + { + const HannWindow<1, float> window; + const auto values{ window.values() }; + + EXPECT_TRUE(std::isfinite(values[0])); + EXPECT_GE(values[0], 0.F); + EXPECT_LE(values[0], 1.F); + EXPECT_FLOAT_EQ(window.energy(), 1.F); + } + + TEST(Window, twoSamplesWindow) + { + const HannWindow<2, float> window; + const auto values{ window.values() }; + + EXPECT_FLOAT_EQ(values[0], 0.F); + EXPECT_FLOAT_EQ(values[1], 0.F); + EXPECT_FLOAT_EQ(window.energy(), 0.F); + } + + TEST(Window, coefficientsAreFiniteAndInRange) + { + const HannWindow<17, float> window; + + for (float v : window.values()) + { + EXPECT_TRUE(std::isfinite(v)); + EXPECT_GE(v, 0.F); + EXPECT_LE(v, 1.F); + } + + EXPECT_GT(window.energy(), 0.F); + } + + TEST(Window, symmetric) + { + const HannWindow<31, float> window; + const auto values{ window.values() }; + + for (std::size_t i{}; i < values.size() / 2; ++i) + EXPECT_NEAR(values[i], values[values.size() - 1 - i], 1e-6F); + } + + TEST(Window, applyUsesPrecomputedCoefficients) + { + constexpr std::size_t size{ 8 }; + const HannWindow window; + + std::vector input(size); + for (std::size_t i{}; i < size; ++i) + input[i] = static_cast(i + 1); + + std::vector output(size); + window.apply(std::span{ input.data(), input.size() }, + std::span{ output.data(), output.size() }); + + const auto coefficients{ window.values() }; + for (std::size_t i{}; i < size; ++i) + EXPECT_FLOAT_EQ(output[i], input[i] * coefficients[i]); + } +} // namespace lms::math::tests \ No newline at end of file diff --git a/src/libs/services/recommendation/CMakeLists.txt b/src/libs/services/recommendation/CMakeLists.txt index 21e89b3a..05124b58 100644 --- a/src/libs/services/recommendation/CMakeLists.txt +++ b/src/libs/services/recommendation/CMakeLists.txt @@ -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 + $<$>:-ffast-math> + ) + +if(BUILD_TESTING) + add_subdirectory(test) +endif() diff --git a/src/libs/services/recommendation/impl/IEngine.hpp b/src/libs/services/recommendation/impl/IEngine.hpp index bdf48f7b..fdaef5a0 100644 --- a/src/libs/services/recommendation/impl/IEngine.hpp +++ b/src/libs/services/recommendation/impl/IEngine.hpp @@ -19,7 +19,7 @@ #pragma once -#include +#include #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& 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 linkTypes, std::size_t maxCount) const = 0; + virtual TrackResults findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const = 0; + virtual TrackResults findSimilarTracks(std::span 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 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 createEngine(db::IDb& db); - } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/PlaylistGeneratorService.cpp b/src/libs/services/recommendation/impl/PlaylistGeneratorService.cpp deleted file mode 100644 index 05a0f892..00000000 --- a/src/libs/services/recommendation/impl/PlaylistGeneratorService.cpp +++ /dev/null @@ -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 . - */ - -#include "PlaylistGeneratorService.hpp" - -#include - -#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 createPlaylistGeneratorService(db::IDb& db, IRecommendationService& recommendationService) - { - return std::make_unique(db, recommendationService); - } - - PlaylistGeneratorService::PlaylistGeneratorService(db::IDb& db, IRecommendationService& recommendationService) - : _db{ db } - , _recommendationService{ recommendationService } - { - _constraints.push_back(std::make_unique(_db)); - _constraints.push_back(std::make_unique(_db)); - _constraints.push_back(std::make_unique()); - } - - std::vector 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 similarTracks{ _recommendationService.findSimilarTracks(tracklistId, maxCount * 2) }; // ask for more tracks than we need as it will be easier to respect constraints - - const std::vector startingTracks{ getTracksFromTrackList(tracklistId) }; - - std::vector finalResult = startingTracks; - finalResult.reserve(startingTracks.size() + maxCount); - - std::vector 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::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 diff --git a/src/libs/services/recommendation/impl/RecommendationService.cpp b/src/libs/services/recommendation/impl/RecommendationService.cpp index e51ba11b..78a54848 100644 --- a/src/libs/services/recommendation/impl/RecommendationService.cpp +++ b/src/libs/services/recommendation/impl/RecommendationService.cpp @@ -19,24 +19,51 @@ #include "RecommendationService.hpp" -#include +#include +#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 createEngine(db::ScanSettings::RecommendationEngineType type, db::IDb& db) + { + switch (type) + { + case db::ScanSettings::RecommendationEngineType::Clusters: + return std::make_unique(db); + case db::ScanSettings::RecommendationEngineType::AudioSimilarity: + return std::make_unique(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& trackIds, std::size_t maxCount) const + TrackResults RecommendationService::findSimilarTracks(std::span 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 linkTypes, std::size_t maxCount) const + ArtistResults RecommendationService::findSimilarArtists(db::ArtistId artistId, core::EnumSet 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 diff --git a/src/libs/services/recommendation/impl/RecommendationService.hpp b/src/libs/services/recommendation/impl/RecommendationService.hpp index 8424e528..2caeea70 100644 --- a/src/libs/services/recommendation/impl/RecommendationService.hpp +++ b/src/libs/services/recommendation/impl/RecommendationService.hpp @@ -19,8 +19,14 @@ #pragma once -#include +#include +#include +#include + +#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& 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 linkTypes, std::size_t maxCount) const override; + void requestReload() override; + bool isLoaded() const override; + EngineType getEngineType() const override; - void setEnginePriorities(const std::vector& engineTypes); - void clearEngines(); - void loadPendingEngine(EngineType engineType, std::unique_ptr engine, bool forceReload, const ProgressCallback& progressCallback); + TrackResults findSimilarTracks(db::TrackListId tracklistId, std::size_t maxCount) const override; + TrackResults findSimilarTracks(std::span 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 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; + mutable std::shared_mutex _mutex; + EngineType _engineType{ EngineType::None }; std::unique_ptr _engine; + boost::asio::io_context _ioContext; + core::IOContextRunner _ioContextRunner; }; } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.hpp new file mode 100644 index 00000000..228225af --- /dev/null +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.hpp @@ -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 . + */ + +#pragma once + +#include +#include +#include + +#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 + 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; + 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 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 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, ReducedDimCount> _pcaBasis{}; + std::array _pcaScale{}; + bool _pcaReady{}; + + // In-memory cache of reduced feature vectors + std::vector _vectors; + std::unordered_map _trackVectors; + std::unordered_map>> _releaseVectors; + std::unordered_map>> _artistVectors; + TrackMetadataMap _trackMetadata; + + FloatType _trackDistanceThreshold{}; + FloatType _releaseDistanceThreshold{}; + FloatType _artistDistanceThreshold{}; + TrackCandidateEvaluator _similarityEvaluator; + TrackCandidateEvaluator _pathEvaluator; + }; +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp new file mode 100644 index 00000000..5440cf5b --- /dev/null +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioSimilarityEngine.impl.hpp @@ -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 . + */ + +#pragma once + +#include "AudioSimilarityEngine.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#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 + TrackResults findNearestNeighbors( + const ReducedVector& queryVector, // expected to be normalized + const std::unordered_map& 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(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 + AudioSimilarityEngine::AudioSimilarityEngine(db::IDb& db) + : _db{ db } + { + } + + template + AudioSimilarityEngine::~AudioSimilarityEngine() = default; + + template + void AudioSimilarityEngine::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()); + _similarityEvaluator.addHardConstraint(std::make_unique(_trackDistanceThreshold)); + _similarityEvaluator.addSoftConstraint(std::make_unique(), interpolationFitWeight); + _similarityEvaluator.addSoftConstraint(std::make_unique(), smoothTransitionWeight); + _similarityEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameReleaseWeight); + _similarityEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameArtistWeight); + + _pathEvaluator = {}; + _pathEvaluator.addHardConstraint(std::make_unique()); + _pathEvaluator.addSoftConstraint(std::make_unique(), interpolationFitWeight); + _pathEvaluator.addSoftConstraint(std::make_unique(), smoothTransitionWeight); + _pathEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameReleaseWeight); + _pathEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameArtistWeight); + } + + template + + TrackResults AudioSimilarityEngine::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 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 + + TrackResults AudioSimilarityEngine::findSimilarTracks(std::span 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 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> 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(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 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 bestIdx; + float bestScore{ std::numeric_limits::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(*bestIdx)); + } + + return res; + } + + template + TrackResults AudioSimilarityEngine::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 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 { + std::optional best; + float bestScore{ std::numeric_limits::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(i + 1) / static_cast(interiorCount + 1) }; + auto queryPoint{ startVector + direction * t }; + queryPoint.normalizeL2(); + + const auto neighbors{ detail::findNearestNeighbors(queryPoint, _trackVectors, neighborCount, endTrackId) }; + std::optional 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 + ReleaseResults AudioSimilarityEngine::findSimilarReleases( + db::ReleaseId releaseId, + std::size_t maxCount) const + { + LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Find similar releases"); + + ResultContainer 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> rankedReleases; + rankedReleases.reserve(_releaseVectors.size()); + + using CosineDistance = math::NormalizedCosineDistance; + + for (const auto& [candidateId, candidateReleaseVectors] : _releaseVectors) + { + if (candidateId == releaseId || candidateReleaseVectors.empty()) + continue; + + const FloatType distance{ math::symmetricalChamferDistance( + 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 + ArtistResults AudioSimilarityEngine::findSimilarArtists(db::ArtistId artistId, core::EnumSet 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> rankedArtists; + rankedArtists.reserve(_artistVectors.size()); + + using CosineDistance = math::NormalizedCosineDistance; + + for (const auto& [candidateId, candidateArtistFeatures] : _artistVectors) + { + if (candidateId == artistId || candidateArtistFeatures.empty()) + continue; + + const FloatType distance{ math::symmetricalChamferDistance( + 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 + void AudioSimilarityEngine::load() + { + LMS_SCOPED_TRACE_OVERVIEW("AudioSimilarityEngine", "Loading"); + + LOG(INFO, "loading..."); + + computeDatasetStats(); + computeReducedFeatures(); + computeTrackDistanceThreshold(); + computeReleaseDistanceThreshold(); + computeArtistDistanceThreshold(); + initializeConstraints(); + + LOG(INFO, "loading complete!"); + } + + template + void AudioSimilarityEngine::computeDatasetStats() + { + LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "Compute dataset stats"); + + LOG(DEBUG, "computing dataset stats..."); + + _pcaReady = false; + _trackCount = 0; + + std::array, 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(statsAccumulators[featureIndex].getMean()); + + // Compute covariance + using AudioFeatureMatrix = math::SquareMatrix; + const auto covariance{ std::make_unique() }; + { + const auto calculator{ std::make_unique>() }; + + 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; + using EigenVector = math::Vector; + + auto covarianceCopy{ std::make_unique() }; + for (std::size_t i{}; i < SourceDimCount; ++i) + { + for (std::size_t j{}; j < SourceDimCount; ++j) + (*covarianceCopy)[i][j] = static_cast((*covariance)[i][j]); + } + + EigenVector eigenValues{}; + auto eigenVectors{ std::make_unique>() }; + + 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((*eigenVectors)[k][j]); + + _pcaScale[k] = (eigenValues[k] > 1e-15) ? static_cast(1.0 / std::sqrt(eigenValues[k])) : FloatType{}; + } + } + + _pcaReady = true; + LOG(DEBUG, "computing dataset stats done"); + } + + template + void AudioSimilarityEngine::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 + void AudioSimilarityEngine::projectToReduced(const SourceVector& sourceVectorCentered, ReducedVector& reducedVector) const + { + assert(_pcaReady); + math::projectOntoBasis(_pcaBasis, sourceVectorCentered, reducedVector, _pcaScale); + } + + template + void AudioSimilarityEngine::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> 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 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> 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 + void AudioSimilarityEngine::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 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 stats; + for (std::size_t i{}; i < sampleCount; ++i) + { + const ReducedVector* queryVector{ allVectors[i] }; + const math::NormalizedCosineDistance distFunc{ *queryVector }; + FloatType minDist{ std::numeric_limits::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::max(); + + LOG(INFO, "track distance threshold = " << _trackDistanceThreshold); + } + + template + void AudioSimilarityEngine::computeReleaseDistanceThreshold() + { + LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeReleaseDistanceThreshold"); + + constexpr std::size_t maxSampleCount{ 200 }; + constexpr float stdDevMultiplier{ 2.F }; + using CosineDistance = math::NormalizedCosineDistance; + + std::vector>*> 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 stats; + for (std::size_t i{}; i < sampleCount; ++i) + { + FloatType minDist{ std::numeric_limits::max() }; + for (const auto* candidate : allProfiles) + { + if (candidate == allProfiles[i]) + continue; + + const FloatType d{ math::symmetricalChamferDistance(*allProfiles[i], *candidate) }; + if (d < minDist) + minDist = d; + } + if (minDist < std::numeric_limits::max()) + stats.add(minDist); + } + + if (stats.getCount() >= 2) + _releaseDistanceThreshold = stats.getMean() + stdDevMultiplier * stats.getSampleStdDev(); + else + _releaseDistanceThreshold = std::numeric_limits::max(); + + LOG(INFO, "release distance threshold = " << _releaseDistanceThreshold); + } + + template + void AudioSimilarityEngine::computeArtistDistanceThreshold() + { + LMS_SCOPED_TRACE_DETAILED("AudioSimilarityEngine", "ComputeArtistDistanceThreshold"); + + constexpr std::size_t maxSampleCount{ 200 }; + constexpr float stdDevMultiplier{ 2.F }; + using CosineDistance = math::NormalizedCosineDistance; + + std::vector>*> 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 stats; + for (std::size_t i{}; i < sampleCount; ++i) + { + FloatType minDist{ std::numeric_limits::max() }; + for (const auto* candidate : allProfiles) + { + if (candidate == allProfiles[i]) + continue; + + const FloatType d{ math::symmetricalChamferDistance(*allProfiles[i], *candidate) }; + if (d < minDist) + minDist = d; + } + if (minDist < std::numeric_limits::max()) + stats.add(minDist); + } + + if (stats.getCount() >= 2) + _artistDistanceThreshold = stats.getMean() + stdDevMultiplier * stats.getSampleStdDev(); + else + _artistDistanceThreshold = std::numeric_limits::max(); + + LOG(INFO, "artist distance threshold = " << _artistDistanceThreshold); + } +} // namespace lms::recommendation + +#undef LOG diff --git a/src/libs/services/recommendation/impl/audio-similarity/AudioVectorProvider.hpp b/src/libs/services/recommendation/impl/audio-similarity/AudioVectorProvider.hpp new file mode 100644 index 00000000..6aba2eeb --- /dev/null +++ b/src/libs/services/recommendation/impl/audio-similarity/AudioVectorProvider.hpp @@ -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 . + */ + +#pragma once + +#include "database/objects/TrackId.hpp" + +namespace lms::db +{ + class Session; +} + +namespace lms::recommendation +{ + template + 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; + provider.visitVectors(session, [](db::TrackId, typename T::Vector&) {}); + }; +} // namespace lms::recommendation \ No newline at end of file diff --git a/src/libs/services/recommendation/impl/ClustersEngineCreator.hpp b/src/libs/services/recommendation/impl/audio-similarity/Types.hpp similarity index 80% rename from src/libs/services/recommendation/impl/ClustersEngineCreator.hpp rename to src/libs/services/recommendation/impl/audio-similarity/Types.hpp index 7d157526..cc7f3d0c 100644 --- a/src/libs/services/recommendation/impl/ClustersEngineCreator.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/Types.hpp @@ -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 - -namespace lms::db -{ - class IDb; -} - namespace lms::recommendation { - class IEngine; - std::unique_ptr createClustersEngine(db::IDb& db); + using FloatType = float; } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/FeaturesEngineCreator.hpp b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingEngine.cpp similarity index 77% rename from src/libs/services/recommendation/impl/FeaturesEngineCreator.hpp rename to src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingEngine.cpp index 3d9bcb2e..17944280 100644 --- a/src/libs/services/recommendation/impl/FeaturesEngineCreator.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingEngine.cpp @@ -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 . */ -#pragma once +#include "audio-similarity/AudioSimilarityEngine.impl.hpp" -#include - -#include "IEngine.hpp" - -namespace lms::db -{ - class IDb; -} +#include "MusicNNEmbeddingEngine.hpp" namespace lms::recommendation { - std::unique_ptr createFeaturesEngine(db::IDb& db); + template class AudioSimilarityEngine; } diff --git a/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingEngine.hpp b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingEngine.hpp new file mode 100644 index 00000000..82c9103d --- /dev/null +++ b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingEngine.hpp @@ -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 . + */ + +#pragma once + +#include "audio-similarity/AudioSimilarityEngine.hpp" + +#include "MusicNNEmbeddingProvider.hpp" + +namespace lms::recommendation +{ + using MusicNNEmbeddingEngine = AudioSimilarityEngine; +} \ No newline at end of file diff --git a/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp new file mode 100644 index 00000000..a817417f --- /dev/null +++ b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.cpp @@ -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 . + */ + +#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& 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& visitor) + { + session.checkReadTransaction(); + + Vector vec; + db::TrackMusicNNEmbeddings::find(session, [&](const db::TrackMusicNNEmbeddings::pointer& embeddings) { + readEmbeddings(embeddings, vec); + visitor(embeddings->getTrackId(), vec); + }); + } +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/include/services/recommendation/IPlaylistGeneratorService.hpp b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.hpp similarity index 56% rename from src/libs/services/recommendation/include/services/recommendation/IPlaylistGeneratorService.hpp rename to src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.hpp index b36dd023..33d6d521 100644 --- a/src/libs/services/recommendation/include/services/recommendation/IPlaylistGeneratorService.hpp +++ b/src/libs/services/recommendation/impl/audio-similarity/musicnn/MusicNNEmbeddingProvider.hpp @@ -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 +#include -#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; - // 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& visitor); }; - - std::unique_ptr createPlaylistGeneratorService(db::IDb& db, IRecommendationService& recommendationService); } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp index e1100d0f..64c38db2 100644 --- a/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp +++ b/src/libs/services/recommendation/impl/clusters/ClustersEngine.cpp @@ -19,6 +19,13 @@ #include "ClustersEngine.hpp" +#include +#include +#include +#include +#include +#include + #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 + std::vector> computeClusterOverlap( + const std::unordered_map>& profileMap, + const std::unordered_set& excludeIds, + const std::unordered_set& queryClusters) + { + std::vector> 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 + ResultContainer findSimilarByClusterOverlap( + const std::unordered_map>& profileMap, + IdType queryId, + const std::vector& queryClusters, + std::size_t maxCount) + { + const std::unordered_set 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 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 createClustersEngine(db::IDb& db) { return std::make_unique(db); } - TrackContainer ClusterEngine::findSimilarTracks(const std::vector& 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()); + _trackEvaluator.addSoftConstraint(std::make_unique(_trackMetadata), sameReleaseWeight); + _trackEvaluator.addSoftConstraint(std::make_unique(_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 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 trackIds, std::size_t maxCount) const + { + LMS_SCOPED_TRACE_DETAILED("ClustersEngine", "Find similar tracks"); + + if (maxCount == 0 || trackIds.empty()) + return {}; + + std::unordered_set 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 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 candidates; + candidates.reserve(candidateCount); + for (const auto& [trackId, count] : overlapCounts) + candidates.push_back(trackId); + + std::vector seeds{ std::cbegin(trackIds), std::cend(trackIds) }; + return greedySelect(std::move(candidates), std::move(seeds), maxCount); + } + + TrackResults ClusterEngine::greedySelect(std::vector candidates, std::vector selectedTracks, std::size_t maxCount) const + { + selectedTracks.reserve(selectedTracks.size() + maxCount); + + TrackResults res; + res.reserve(maxCount); + + while (res.size() < maxCount && !candidates.empty()) + { + std::optional bestIdx; + float bestScore{ std::numeric_limits::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(*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 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 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(_releaseClusters, releaseId, queryIt->second, maxCount); + } + + ArtistResults ClusterEngine::findSimilarArtists(db::ArtistId artistId, core::EnumSet 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(_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{ .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(maxCount, 2)); + res.push_back({ .id = startTrackId, .distance = {} }); + if (maxCount > 1) + res.push_back({ .id = endTrackId, .distance = {} }); + + return res; } } // namespace lms::recommendation + +#undef LOG diff --git a/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp b/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp index 4ae10610..f123c15e 100644 --- a/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp +++ b/src/libs/services/recommendation/impl/clusters/ClustersEngine.hpp @@ -19,33 +19,52 @@ #pragma once +#include +#include +#include + +#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& 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 linkTypes, std::size_t maxCount) const override; + TrackResults findSimilarTracksFromTrackList(db::TrackListId tracklistId, std::size_t maxCount) const override; + TrackResults findSimilarTracks(std::span 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 linkTypes, std::size_t maxCount) const override; + + TrackResults greedySelect(std::vector candidates, std::vector 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> _trackClusters; + std::unordered_map> _releaseClusters; + std::unordered_map> _artistClusters; + TrackCandidateEvaluator _trackEvaluator; + }; } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/features/FeaturesDefs.cpp b/src/libs/services/recommendation/impl/features/FeaturesDefs.cpp deleted file mode 100644 index 20a21d1e..00000000 --- a/src/libs/services/recommendation/impl/features/FeaturesDefs.cpp +++ /dev/null @@ -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 . - */ - -#include "FeaturesDefs.hpp" - -#include -#include - -#include "core/Exception.hpp" - -namespace lms::recommendation -{ - - static const std::unordered_map 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 diff --git a/src/libs/services/recommendation/impl/features/FeaturesEngine.cpp b/src/libs/services/recommendation/impl/features/FeaturesEngine.cpp deleted file mode 100644 index 8da6c6d4..00000000 --- a/src/libs/services/recommendation/impl/features/FeaturesEngine.cpp +++ /dev/null @@ -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 . - */ - -#include "FeaturesEngine.hpp" - -#include - -#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 createFeaturesEngine(db::IDb& db) - { - return std::make_unique(db); - } - - namespace - { - std::optional convertFeatureValuesMapToInputVector(const FeatureValuesMap& featureValuesMap, std::size_t nbDimensions) - { - std::size_t i{}; - std::optional 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 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 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 samples; - std::vector 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 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(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& 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 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 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 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(network); - - LMS_LOG(RECOMMENDATION, INFO, "Classifier successfully loaded!"); - } - -} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/features/FeaturesEngine.hpp b/src/libs/services/recommendation/impl/features/FeaturesEngine.hpp deleted file mode 100644 index a3a13b47..00000000 --- a/src/libs/services/recommendation/impl/features/FeaturesEngine.hpp +++ /dev/null @@ -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 . - */ - -#pragma once - -#include -#include -#include -#include -#include -#include - -#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& 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 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 - using ObjectPositions = std::unordered_map>; - - using ArtistPositions = ObjectPositions; - using ReleasePositions = ObjectPositions; - using TrackPositions = ObjectPositions; - - template - using ObjectMatrix = som::Matrix>; - using ArtistMatrix = ObjectMatrix; - using ReleaseMatrix = ObjectMatrix; - using TrackMatrix = ObjectMatrix; - - void load(const som::Network& network, const TrackPositions& tracksPosition); - - FeaturesEngineCache toCache() const; - - template - static std::vector getMatchingRefVectorsPosition(const std::vector& ids, const ObjectPositions& objectPositions); - - template - static std::vector getObjectsIds(const std::vector& positions, const ObjectMatrix& objectsMatrix); - - template - std::vector getSimilarObjects(const std::vector& ids, - const ObjectMatrix& objectMatrix, - const ObjectPositions& objectPositions, - std::size_t maxCount) const; - - db::IDb& _db; - bool _loadCancelled{}; - std::unique_ptr _network; - double _networkRefVectorsDistanceMedian{}; - - ArtistPositions _artistPositions; - std::unordered_map _artistMatrix; - - ReleasePositions _releasePositions; - ReleaseMatrix _releaseMatrix; - - TrackPositions _trackPositions; - TrackMatrix _trackMatrix; - }; - - template - std::vector FeaturesEngine::getMatchingRefVectorsPosition(const std::vector& ids, const ObjectPositions& objectPositions) - { - std::vector 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 - std::vector FeaturesEngine::getObjectsIds(const std::vector& positions, const ObjectMatrix& objectMatrix) - { - std::vector 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 - std::vector FeaturesEngine::getSimilarObjects(const std::vector& ids, - const ObjectMatrix& objectMatrix, - const ObjectPositions& objectPositions, - std::size_t maxCount) const - { - std::vector res; - - std::vector searchedRefVectorsPosition{ getMatchingRefVectorsPosition(ids, objectPositions) }; - if (searchedRefVectorsPosition.empty()) - return res; - - while (1) - { - std::vector 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 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 diff --git a/src/libs/services/recommendation/impl/features/FeaturesEngineCache.cpp b/src/libs/services/recommendation/impl/features/FeaturesEngineCache.cpp deleted file mode 100644 index 5734fe45..00000000 --- a/src/libs/services/recommendation/impl/features/FeaturesEngineCache.cpp +++ /dev/null @@ -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 . - */ - -#include "FeaturesEngineCache.hpp" - -#include -#include - -#include "core/IConfig.hpp" -#include "core/ILogger.hpp" -#include "core/Service.hpp" - -namespace lms::recommendation -{ - namespace - { - std::filesystem::path getCacheDirectory() - { - return core::Service::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 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("width") }; - som::Coordinate height{ root.get("height") }; - std::size_t dimCount{ root.get("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(); - - res.setDataWeights(weights); - } - - for (const auto& node : root.get_child("ref_vectors")) - { - som::Coordinate x{ node.second.get("coord_x") }; - som::Coordinate y{ node.second.get("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(); - - 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::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("id") }; - for (const auto& position : object.second.get_child("position")) - { - auto x = position.second.get("x"); - auto y = position.second.get("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::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::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 diff --git a/src/libs/services/recommendation/impl/features/FeaturesEngineCache.hpp b/src/libs/services/recommendation/impl/features/FeaturesEngineCache.hpp deleted file mode 100644 index 1b4794ae..00000000 --- a/src/libs/services/recommendation/impl/features/FeaturesEngineCache.hpp +++ /dev/null @@ -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 . - */ - -#pragma once - -#include -#include - -#include "database/objects/TrackId.hpp" -#include "som/Network.hpp" - -namespace lms::recommendation -{ - - class FeaturesEngineCache - { - public: - static void invalidate(); - - static std::optional read(); - void write() const; - - private: - using TrackPositions = std::unordered_map>; - - FeaturesEngineCache(som::Network network, TrackPositions trackPositions); - - static std::optional createNetworkFromCacheFile(const std::filesystem::path& path); - static std::optional 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 diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.cpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.cpp deleted file mode 100644 index ab157221..00000000 --- a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.cpp +++ /dev/null @@ -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 . - */ - -#include "ConsecutiveArtists.hpp" - -#include - -#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& 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(i); - - if (trackIndex + i < trackIds.size()) - score += countCommonArtists(artists, getArtists(trackIds[trackIndex + i])) / static_cast(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 diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.hpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.hpp deleted file mode 100644 index 4082b927..00000000 --- a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveArtists.hpp +++ /dev/null @@ -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 . - */ - -#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 diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.cpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.cpp deleted file mode 100644 index 9b0f2802..00000000 --- a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.cpp +++ /dev/null @@ -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 . - */ - -#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& 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(i)); - - if ((trackIndex + i < trackIds.size()) && getReleaseId(trackIds[trackIndex + i]) == releaseId) - score += (1.F / static_cast(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 diff --git a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.hpp b/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.hpp deleted file mode 100644 index 819d4ac1..00000000 --- a/src/libs/services/recommendation/impl/playlist-constraints/ConsecutiveReleases.hpp +++ /dev/null @@ -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 . - */ - -#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& trackIds, std::size_t trackIndex) override; - - db::ReleaseId getReleaseId(db::TrackId trackId); - - db::IDb& _db; - }; -} // namespace lms::recommendation::PlaylistGeneratorConstraint diff --git a/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.cpp b/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.cpp deleted file mode 100644 index 50f48980..00000000 --- a/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.cpp +++ /dev/null @@ -1,31 +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 . - */ - -#include "DuplicateTracks.hpp" - -#include - -namespace lms::recommendation::PlaylistGeneratorConstraint -{ - float DuplicateTracks::computeScore(const std::vector& trackIds, std::size_t trackIndex) - { - const auto count{ std::count(std::cbegin(trackIds), std::cend(trackIds), trackIds[trackIndex]) }; - return count == 1 ? 0 : 1'000; - } -} // namespace lms::recommendation::PlaylistGeneratorConstraint diff --git a/src/libs/services/recommendation/impl/playlist-constraints/IConstraint.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/DuplicateTrackConstraint.hpp similarity index 57% rename from src/libs/services/recommendation/impl/playlist-constraints/IConstraint.hpp rename to src/libs/services/recommendation/impl/track-selection-constraints/DuplicateTrackConstraint.hpp index 1d01c299..128ec932 100644 --- a/src/libs/services/recommendation/impl/playlist-constraints/IConstraint.hpp +++ b/src/libs/services/recommendation/impl/track-selection-constraints/DuplicateTrackConstraint.hpp @@ -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 diff --git a/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/ITrackCandidateHardConstraint.hpp similarity index 66% rename from src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.hpp rename to src/libs/services/recommendation/impl/track-selection-constraints/ITrackCandidateHardConstraint.hpp index 86bc267a..fcd5bd06 100644 --- a/src/libs/services/recommendation/impl/playlist-constraints/DuplicateTracks.hpp +++ b/src/libs/services/recommendation/impl/track-selection-constraints/ITrackCandidateHardConstraint.hpp @@ -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& 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 diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/ITrackCandidateSoftConstraint.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/ITrackCandidateSoftConstraint.hpp new file mode 100644 index 00000000..441b123b --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/ITrackCandidateSoftConstraint.hpp @@ -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 . + */ + +#pragma once + +#include "TrackCandidateContext.hpp" + +namespace lms::recommendation +{ + class ITrackCandidateSoftConstraint + { + public: + virtual ~ITrackCandidateSoftConstraint() = default; + + virtual float computeScore(const TrackCandidateContext& context) const = 0; + }; +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/InterpolationFitConstraint.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/InterpolationFitConstraint.hpp new file mode 100644 index 00000000..06a5a492 --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/InterpolationFitConstraint.hpp @@ -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 . + */ + +#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 diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/MaxDistanceConstraint.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/MaxDistanceConstraint.hpp new file mode 100644 index 00000000..443b05c1 --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/MaxDistanceConstraint.hpp @@ -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 . + */ + +#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 diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/SameArtistConstraint.cpp b/src/libs/services/recommendation/impl/track-selection-constraints/SameArtistConstraint.cpp new file mode 100644 index 00000000..9d337d3f --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/SameArtistConstraint.cpp @@ -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 . + */ + +#include "SameArtistConstraint.hpp" + +#include + +#include "TrackCandidateContext.hpp" + +namespace lms::recommendation +{ + namespace + { + bool hasCommonArtist(const std::vector& a, const std::vector& 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(i); + } + return score; + } +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/PlaylistGeneratorService.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/SameArtistConstraint.hpp similarity index 51% rename from src/libs/services/recommendation/impl/PlaylistGeneratorService.hpp rename to src/libs/services/recommendation/impl/track-selection-constraints/SameArtistConstraint.hpp index 0e6f88f0..51d4e458 100644 --- a/src/libs/services/recommendation/impl/PlaylistGeneratorService.hpp +++ b/src/libs/services/recommendation/impl/track-selection-constraints/SameArtistConstraint.hpp @@ -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> _constraints; + const TrackMetadataMap& _trackMetadata; + const std::size_t _window; }; } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/SameReleaseConstraint.cpp b/src/libs/services/recommendation/impl/track-selection-constraints/SameReleaseConstraint.cpp new file mode 100644 index 00000000..3d9bd9d6 --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/SameReleaseConstraint.cpp @@ -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 . + */ + +#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(i); + } + return score; + } +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/SameReleaseConstraint.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/SameReleaseConstraint.hpp new file mode 100644 index 00000000..00933fa4 --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/SameReleaseConstraint.hpp @@ -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 . + */ + +#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 diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/SmoothTransitionConstraint.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/SmoothTransitionConstraint.hpp new file mode 100644 index 00000000..e911ad86 --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/SmoothTransitionConstraint.hpp @@ -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 . + */ + +#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 diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/TrackCandidateContext.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/TrackCandidateContext.hpp new file mode 100644 index 00000000..625299fc --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/TrackCandidateContext.hpp @@ -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 . + */ + +#pragma once + +#include + +#include "database/objects/TrackId.hpp" + +namespace lms::recommendation +{ + struct TrackCandidateContext + { + db::TrackId candidateTrackId; + std::span selectedTracks; + float distanceToQuery{}; + float distanceToPrevious{}; + }; +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/track-selection-constraints/TrackCandidateEvaluator.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/TrackCandidateEvaluator.hpp new file mode 100644 index 00000000..336983a9 --- /dev/null +++ b/src/libs/services/recommendation/impl/track-selection-constraints/TrackCandidateEvaluator.hpp @@ -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 . + */ + +#pragma once + +#include +#include + +#include "ITrackCandidateHardConstraint.hpp" +#include "ITrackCandidateSoftConstraint.hpp" + +namespace lms::recommendation +{ + class TrackCandidateEvaluator + { + public: + struct WeightedSoftConstraint + { + std::unique_ptr constraint; + float weight{ 1.0F }; + }; + + void addSoftConstraint(std::unique_ptr constraint, float weight = 1.0F) + { + _softConstraints.emplace_back(WeightedSoftConstraint{ .constraint = std::move(constraint), .weight = weight }); + } + + void addHardConstraint(std::unique_ptr 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 _softConstraints; + std::vector> _hardConstraints; + }; +} // namespace lms::recommendation diff --git a/src/libs/services/recommendation/impl/features/FeaturesDefs.hpp b/src/libs/services/recommendation/impl/track-selection-constraints/TrackMetadata.hpp similarity index 55% rename from src/libs/services/recommendation/impl/features/FeaturesDefs.hpp rename to src/libs/services/recommendation/impl/track-selection-constraints/TrackMetadata.hpp index dbb0ddf6..9dff70a7 100644 --- a/src/libs/services/recommendation/impl/features/FeaturesDefs.hpp +++ b/src/libs/services/recommendation/impl/track-selection-constraints/TrackMetadata.hpp @@ -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 #include -#include #include +#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; - using FeatureValue = double; - using FeatureValues = std::vector; - using FeatureValuesMap = std::unordered_map; - - struct FeatureDef + struct TrackMetadata { - std::size_t nbDimensions{}; + db::ReleaseId releaseId; // invalid if track has no release + std::vector 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; - + using TrackMetadataMap = std::unordered_map; } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp b/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp index b0520482..65c576c5 100644 --- a/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp +++ b/src/libs/services/recommendation/include/services/recommendation/IRecommendationService.hpp @@ -20,7 +20,7 @@ #pragma once #include -#include +#include #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& 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 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 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 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 createRecommendationService(db::IDb& db); diff --git a/src/libs/services/recommendation/include/services/recommendation/Types.hpp b/src/libs/services/recommendation/include/services/recommendation/Types.hpp index b593ce80..bd08ec1e 100644 --- a/src/libs/services/recommendation/include/services/recommendation/Types.hpp +++ b/src/libs/services/recommendation/include/services/recommendation/Types.hpp @@ -19,7 +19,7 @@ #pragma once -#include +#include #include "database/objects/ArtistId.hpp" #include "database/objects/ReleaseId.hpp" @@ -27,18 +27,17 @@ namespace lms::recommendation { - struct Progress + template + 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; template - using ResultContainer = std::vector; - - using ArtistContainer = ResultContainer; - using ReleaseContainer = ResultContainer; - using TrackContainer = ResultContainer; + using ResultContainer = std::vector>; + using ArtistResults = ResultContainer; + using ReleaseResults = ResultContainer; + using TrackResults = ResultContainer; } // namespace lms::recommendation diff --git a/src/libs/services/recommendation/test/CMakeLists.txt b/src/libs/services/recommendation/test/CMakeLists.txt new file mode 100644 index 00000000..1c748704 --- /dev/null +++ b/src/libs/services/recommendation/test/CMakeLists.txt @@ -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() diff --git a/src/libs/services/recommendation/test/ConstraintsTest.cpp b/src/libs/services/recommendation/test/ConstraintsTest.cpp new file mode 100644 index 00000000..ebc4e25e --- /dev/null +++ b/src/libs/services/recommendation/test/ConstraintsTest.cpp @@ -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 . + */ + +#include + +#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 selected{ T1, T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T3, .selectedTracks = selected }; + EXPECT_FALSE(DuplicateTrackConstraint{}.rejects(ctx)); +} + +TEST(DuplicateTrackConstraint, rejectsAlreadySelected) +{ + const std::vector 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 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 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 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 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 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 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 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 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()); + + const std::vector 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(meta), 2.F); + + const std::vector 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(meta), 1.F); + evaluator.addSoftConstraint(std::make_unique(meta), 1.F); + + const std::vector 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()); + evaluator.addSoftConstraint(std::make_unique(meta), 1.F); + + const std::vector selected{ T2 }; + const TrackCandidateContext ctx{ .candidateTrackId = T1, .selectedTracks = selected }; + EXPECT_FALSE(evaluator.rejects(ctx)); + EXPECT_FLOAT_EQ(evaluator.score(ctx), 1.F); +} diff --git a/src/libs/services/scanner/CMakeLists.txt b/src/libs/services/scanner/CMakeLists.txt index ce4a8d6d..ca467e45 100644 --- a/src/libs/services/scanner/CMakeLists.txt +++ b/src/libs/services/scanner/CMakeLists.txt @@ -28,6 +28,7 @@ add_library(lmsscanner STATIC impl/steps/ScanStepCheckForRemovedFiles.cpp impl/steps/ScanStepCompact.cpp impl/steps/ScanStepComputeClusterStats.cpp + impl/steps/ScanStepExtractMusicNNEmbeddings.cpp impl/steps/ScanStepOptimize.cpp impl/steps/ScanStepRemoveOrphanedDbEntries.cpp impl/steps/ScanStepScanFiles.cpp diff --git a/src/libs/services/scanner/impl/ScannerService.cpp b/src/libs/services/scanner/impl/ScannerService.cpp index 4834547b..efa04576 100644 --- a/src/libs/services/scanner/impl/ScannerService.cpp +++ b/src/libs/services/scanner/impl/ScannerService.cpp @@ -49,6 +49,7 @@ #include "steps/ScanStepCheckForRemovedFiles.hpp" #include "steps/ScanStepCompact.hpp" #include "steps/ScanStepComputeClusterStats.hpp" +#include "steps/ScanStepExtractMusicNNEmbeddings.hpp" #include "steps/ScanStepOptimize.hpp" #include "steps/ScanStepRemoveOrphanedDbEntries.hpp" #include "steps/ScanStepScanFiles.hpp" @@ -123,6 +124,10 @@ namespace lms::scanner settings->allowArtistMBIDFallback = scanSettings->getAllowMBIDArtistMerge(); settings->artistImageFallbackToRelease = scanSettings->getArtistImageFallbackToReleaseField(); + settings->extractMusicNNEmbeddings = scanSettings->getRecommendationEngineType() == db::ScanSettings::RecommendationEngineType::AudioSimilarity; + settings->musicnnModelPath = core::Service::get()->getPath("musicnn-model-path", "/usr/share/lms/models/MSD_musicnn_embedding.onnx"); + settings->musicnnMaxPatchCountPerTrack = core::Service::get()->getULong("musicnn-max-patch-count-per-track", 20); + // TODO, store this in DB + expose in UI settings->skipDuplicateTrackMBID = core::Service::get()->getBool("scanner-skip-duplicate-mbid", false); @@ -164,6 +169,8 @@ namespace lms::scanner , _jobScheduler{ core::createJobScheduler("Scanner", getScannerThreadCount()) } , _cachePath{ cachePath } { + LMS_LOG(DBUPDATER, INFO, "Starting service..."); + _ioService.setThreadCount(1); LMS_LOG(DBUPDATER, INFO, "Using " << _jobScheduler->getThreadCount() << " thread(s) for jobs"); @@ -186,6 +193,8 @@ namespace lms::scanner refreshScanSettings(); start(); + + LMS_LOG(DBUPDATER, INFO, "Service started!"); } ScannerService::~ScannerService() @@ -389,7 +398,7 @@ namespace lms::scanner } refreshTracingLoggerStats(); - LMS_LOG(DBUPDATER, INFO, "Scan " << (_abortScan ? "aborted" : "complete") << ". Changes = " << stats.getChangesCount() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << ", failures = " << stats.failures << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errorsCount << "), features fetched = " << stats.featuresFetched << ", duplicates = " << stats.duplicates.size()); + LMS_LOG(DBUPDATER, INFO, "Scan " << (_abortScan ? "aborted" : "complete") << ". Changes = " << stats.getChangesCount() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << ", failures = " << stats.failures << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errorsCount << "), audio features extracted = " << stats.featureExtractions << ", duplicates = " << stats.duplicates.size()); { auto transaction{ _db.getTLSSession().createReadTransaction() }; @@ -518,6 +527,10 @@ namespace lms::scanner _scanSteps.emplace_back(std::make_unique(params)); _scanSteps.emplace_back(std::make_unique(params)); _scanSteps.emplace_back(std::make_unique(params)); + + // Audio extraction scan step must be last as it is the most long running + if (_settings.extractMusicNNEmbeddings) + _scanSteps.emplace_back(std::make_unique(params, _settings.musicnnModelPath, _settings.musicnnMaxPatchCountPerTrack)); } void ScannerService::notifyInProgress(const ScanStepStats& stepStats) diff --git a/src/libs/services/scanner/impl/ScannerSettings.hpp b/src/libs/services/scanner/impl/ScannerSettings.hpp index 0a450b3b..562dedd7 100644 --- a/src/libs/services/scanner/impl/ScannerSettings.hpp +++ b/src/libs/services/scanner/impl/ScannerSettings.hpp @@ -47,6 +47,9 @@ namespace lms::scanner bool skipSingleReleasePlayLists{}; bool allowArtistMBIDFallback{ true }; bool artistImageFallbackToRelease{}; + bool extractMusicNNEmbeddings{}; + std::filesystem::path musicnnModelPath; + std::size_t musicnnMaxPatchCountPerTrack{}; std::vector mediaLibraries; diff --git a/src/libs/services/scanner/impl/ScannerStats.cpp b/src/libs/services/scanner/impl/ScannerStats.cpp index 74f15f14..7667935f 100644 --- a/src/libs/services/scanner/impl/ScannerStats.cpp +++ b/src/libs/services/scanner/impl/ScannerStats.cpp @@ -34,7 +34,6 @@ namespace lms::scanner unsigned ScanStepStats::progress() const { const unsigned res{ static_cast((processedElems / static_cast(totalElems ? totalElems : 1)) * 100) }; - // can technically be above 100% since we may add files while iterating the filesystem return res; } } // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp index ac0d12c4..8aabf041 100644 --- a/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp +++ b/src/libs/services/scanner/impl/scanners/audiofile/AudioFileScanOperation.cpp @@ -43,8 +43,8 @@ #include "database/objects/TrackArtistLink.hpp" #include "database/objects/TrackEmbeddedImage.hpp" #include "database/objects/TrackEmbeddedImageLink.hpp" -#include "database/objects/TrackFeatures.hpp" #include "database/objects/TrackLyrics.hpp" +#include "database/objects/TrackMusicNNEmbeddings.hpp" #include "image/Exception.hpp" #include "image/Image.hpp" @@ -554,7 +554,7 @@ namespace lms::scanner info.type = image.type; { LMS_SCOPED_TRACE_DETAILED("Scanner", "ImageHash"); - info.hash = core::xxHash3_64(image.data); + info.hash = core::XxHash3_64::hash(image.data); } info.size = image.data.size(); info.mimeType = image.mimeType; @@ -581,6 +581,28 @@ namespace lms::scanner } } + // Returns true if any value actually changed. + bool updateAudioProperties(db::Track::pointer& track, const audio::AudioProperties& props) + { + const bool changed{ track->getDuration() != props.duration + || track->getContainer() != props.container + || track->getCodec() != props.codec + || track->getBitrate() != props.bitrate + || track->getChannelCount() != props.channelCount + || track->getSampleRate() != props.sampleRate + || track->getBitsPerSample() != props.bitsPerSample }; + + track.modify()->setDuration(props.duration); + track.modify()->setContainer(props.container); + track.modify()->setCodec(props.codec); + track.modify()->setBitrate(props.bitrate); + track.modify()->setChannelCount(props.channelCount); + track.modify()->setSampleRate(props.sampleRate); + track.modify()->setBitsPerSample(props.bitsPerSample); + + return changed; + } + AudioFileScanOperation::OperationResult AudioFileScanOperation::processResult() { LMS_SCOPED_TRACE_DETAILED("Scanner", "ProcessAudioScanData"); @@ -700,13 +722,7 @@ namespace lms::scanner track.modify()->setScanVersion(getScannerSettings().audioScanVersion); // Audio properties - track.modify()->setDuration(_file->audioProperties.duration); - track.modify()->setContainer(_file->audioProperties.container); - track.modify()->setCodec(_file->audioProperties.codec); - track.modify()->setBitrate(_file->audioProperties.bitrate); - track.modify()->setChannelCount(_file->audioProperties.channelCount); - track.modify()->setSampleRate(_file->audioProperties.sampleRate); - track.modify()->setBitsPerSample(_file->audioProperties.bitsPerSample); + const bool audioPropertiesChanged{ updateAudioProperties(track, _file->audioProperties) }; track.modify()->setFileSize(getFileSize()); track.modify()->setLastWriteTime(getLastWriteTime()); @@ -772,8 +788,11 @@ namespace lms::scanner track.modify()->setRecordingMBID(_file->track.recordingMBID); track.modify()->setTrackMBID(_file->track.mbid); - if (auto trackFeatures{ db::TrackFeatures::find(dbSession, track->getId()) }) - trackFeatures.remove(); // TODO: only if MBID changed? + if (audioPropertiesChanged) + { + if (auto musicnnEmbedding{ db::TrackMusicNNEmbeddings::find(dbSession, track->getId()) }) + musicnnEmbedding.remove(); + } track.modify()->setCopyright(_file->track.copyright); track.modify()->setCopyrightURL(_file->track.copyrightURL); track.modify()->setAdvisory(getAdvisory(_file->track.advisory)); diff --git a/src/libs/services/scanner/impl/steps/ScanErrorLogger.cpp b/src/libs/services/scanner/impl/steps/ScanErrorLogger.cpp index c82aa81e..9c7b125d 100644 --- a/src/libs/services/scanner/impl/steps/ScanErrorLogger.cpp +++ b/src/libs/services/scanner/impl/steps/ScanErrorLogger.cpp @@ -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 \ No newline at end of file diff --git a/src/libs/services/scanner/impl/steps/ScanErrorLogger.hpp b/src/libs/services/scanner/impl/steps/ScanErrorLogger.hpp index fbf45d75..8e0b3ebe 100644 --- a/src/libs/services/scanner/impl/steps/ScanErrorLogger.hpp +++ b/src/libs/services/scanner/impl/steps/ScanErrorLogger.hpp @@ -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 \ No newline at end of file diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp index 4b21ff37..8099e59a 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateArtistImages.cpp @@ -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() }; diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp index 895e0a9f..2bbcb7ee 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateMediumImages.cpp @@ -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() }; diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp index 2e1a3cad..e6887248 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociatePlayListTracks.cpp @@ -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() }; diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp index 5a91a074..fb06c839 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateReleaseImages.cpp @@ -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() }; diff --git a/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp b/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp index 592b0b56..ddf683bf 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepAssociateTrackImages.cpp @@ -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() }; diff --git a/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp index 221f050f..df2e93dd 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepCheckForRemovedFiles.cpp @@ -135,7 +135,7 @@ namespace lms::scanner constexpr std::size_t writeBatchSize{ 50 }; std::vector 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) { diff --git a/src/libs/services/scanner/impl/steps/ScanStepComputeClusterStats.cpp b/src/libs/services/scanner/impl/steps/ScanStepComputeClusterStats.cpp index 3089aa8a..e8655afc 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepComputeClusterStats.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepComputeClusterStats.cpp @@ -18,7 +18,9 @@ */ #include "ScanStepComputeClusterStats.hpp" + #include "core/ILogger.hpp" + #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/objects/Cluster.hpp" diff --git a/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.cpp b/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.cpp new file mode 100644 index 00000000..6ac646aa --- /dev/null +++ b/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.cpp @@ -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 . + */ + +#include "ScanStepExtractMusicNNEmbeddings.hpp" + +#include +#include + +#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 embeddings; + }; + using TrackEmbeddingAssociationContainer = std::deque; + + 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 _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 blob(sizeof(audio::TrackMusicNNEmbeddings)); + audio::trackMusicNNEmbeddingsToBlob(*assoc.embeddings, blob); + db::TrackMusicNNEmbeddings::pointer entry{ session.create(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> jobs) { + if (_abortScan) + return; + + for (const auto& job : jobs) + { + const auto& extractJob{ static_cast(*job) }; + + if (const audio::TrackMusicNNEmbeddings * embeddings{ extractJob.getEmbeddings() }) + pendingAssocs.push_back(TrackEmbeddingAssociation{ .trackId = extractJob.getTrackLocation().track, .embeddings = *embeddings }); + else + addError(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(*_embeddingExtractor, trackLocation)); + } + + writeEmbeddings(context, dbSession, pendingAssocs, false); + } +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.hpp b/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.hpp new file mode 100644 index 00000000..15626450 --- /dev/null +++ b/src/libs/services/scanner/impl/steps/ScanStepExtractMusicNNEmbeddings.hpp @@ -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 . + */ + +#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 _embeddingExtractor; + }; +} // namespace lms::scanner diff --git a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp index 3953a318..7d1c690f 100644 --- a/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp +++ b/src/libs/services/scanner/impl/steps/ScanStepScanFiles.cpp @@ -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> operations; diff --git a/src/libs/services/scanner/impl/steps/TrackLocation.hpp b/src/libs/services/scanner/impl/steps/TrackLocation.hpp new file mode 100644 index 00000000..8efc2c24 --- /dev/null +++ b/src/libs/services/scanner/impl/steps/TrackLocation.hpp @@ -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 . + */ + +#pragma once + +#include + +#include "database/objects/TrackId.hpp" + +namespace lms::scanner +{ + struct TrackLocation + { + db::TrackId track; + std::filesystem::path trackPath; + }; +} // namespace lms::scanner \ No newline at end of file diff --git a/src/libs/services/scanner/include/services/scanner/ScanErrors.hpp b/src/libs/services/scanner/include/services/scanner/ScanErrors.hpp index abf4ee14..bbd75310 100644 --- a/src/libs/services/scanner/include/services/scanner/ScanErrors.hpp +++ b/src/libs/services/scanner/include/services/scanner/ScanErrors.hpp @@ -38,6 +38,7 @@ namespace lms::scanner struct PlayListFileScanError; struct PlayListFilePathMissingError; struct PlayListFileAllPathesMissingError; + struct MusicNNEmbeddingsExtractError; // Visitor interface struct ScanErrorVisitor @@ -55,8 +56,9 @@ namespace lms::scanner virtual void visit(const ImageFileScanError&) = 0; virtual void visit(const LyricsFileScanError&) = 0; virtual void visit(const PlayListFileScanError&) = 0; - virtual void visit(const scanner::PlayListFilePathMissingError& error) = 0; - virtual void visit(const scanner::PlayListFileAllPathesMissingError& error) = 0; + virtual void visit(const PlayListFilePathMissingError& error) = 0; + virtual void visit(const PlayListFileAllPathesMissingError& error) = 0; + virtual void visit(const MusicNNEmbeddingsExtractError& error) = 0; }; struct ScanError @@ -210,4 +212,19 @@ namespace lms::scanner visitor.visit(*this); } }; + + struct MusicNNEmbeddingsExtractError : public ScanError + { + MusicNNEmbeddingsExtractError(const std::filesystem::path& p, std::string_view e) + : ScanError{ p } + , errorMsg{ e } {} + + void accept(ScanErrorVisitor& visitor) const override + { + visitor.visit(*this); + } + + std::string errorMsg; + }; + } // namespace lms::scanner diff --git a/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp b/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp index 40ceb52d..912b583a 100644 --- a/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp +++ b/src/libs/services/scanner/include/services/scanner/ScannerStats.hpp @@ -54,9 +54,10 @@ namespace lms::scanner CheckForRemovedFiles, ComputeClusterStats, Compact, + ExtractMusicNNEmbeddings, Optimize, ReconciliateArtists, - ReloadSimilarityEngine, + ReloadRecommendationEngine, RemoveOrphanedDbEntries, ScanFiles, UpdateLibraryFields, @@ -92,7 +93,7 @@ namespace lms::scanner std::size_t updates{}; // updated file in DB std::size_t failures{}; // scan failure - std::size_t featuresFetched{}; // features fetched in DB + std::size_t featureExtractions{}; // features extracted in DB static constexpr std::size_t maxStoredErrorCount{ 5'000 }; // TODO make this configurable std::vector> errors; diff --git a/src/libs/services/scanner/test/CMakeLists.txt b/src/libs/services/scanner/test/CMakeLists.txt index a2210067..bcc6ebc3 100644 --- a/src/libs/services/scanner/test/CMakeLists.txt +++ b/src/libs/services/scanner/test/CMakeLists.txt @@ -5,7 +5,7 @@ add_executable(test-scanner AudioFileUtils.cpp Lyrics.cpp PlayList.cpp - Scanner.cpp + ScannerStats.cpp TrackMetadataParser.cpp ) @@ -17,6 +17,7 @@ target_link_libraries(test-scanner PRIVATE lmsscanner lmsaudio GTest::GTest + GTest::gtest_main ) if (NOT CMAKE_CROSSCOMPILING) diff --git a/src/libs/services/scanner/test/ScannerStats.cpp b/src/libs/services/scanner/test/ScannerStats.cpp new file mode 100644 index 00000000..07461ce5 --- /dev/null +++ b/src/libs/services/scanner/test/ScannerStats.cpp @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include + +#include "services/scanner/ScannerStats.hpp" + +namespace lms::scanner::tests +{ + TEST(ScannerStats, totalFileCount) + { + ScanStats stats; + stats.skips = 5; + stats.additions = 3; + stats.updates = 7; + stats.failures = 2; + + EXPECT_EQ(stats.getTotalFileCount(), 17U); + } + + TEST(ScannerStats, changesCount) + { + ScanStats stats; + stats.additions = 4; + stats.deletions = 6; + stats.updates = 8; + + EXPECT_EQ(stats.getChangesCount(), 18U); + } + + TEST(ScannerStats, progressWithZeroTotal) + { + ScanStepStats stepStats; + stepStats.totalElems = 0; + stepStats.processedElems = 0; + + EXPECT_EQ(stepStats.progress(), 0U); + } + + TEST(ScannerStats, progressAt100Percent) + { + ScanStepStats stepStats; + stepStats.totalElems = 50; + stepStats.processedElems = 50; + + EXPECT_EQ(stepStats.progress(), 100U); + } + + TEST(ScannerStats, progressCanExceed100Percent) + { + ScanStepStats stepStats; + stepStats.totalElems = 2; + stepStats.processedElems = 5; + + EXPECT_EQ(stepStats.progress(), 250U); + } +} // namespace lms::scanner::tests diff --git a/src/libs/som/CMakeLists.txt b/src/libs/som/CMakeLists.txt deleted file mode 100644 index 79c1ac54..00000000 --- a/src/libs/som/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -add_library(lmssom STATIC - impl/DataNormalizer.cpp - impl/Network.cpp - ) - -target_include_directories(lmssom INTERFACE - include - ) - -target_include_directories(lmssom PRIVATE - include - ) - -target_link_libraries(lmssom PUBLIC - lmscore - ) - -set_property(TARGET lmssom PROPERTY POSITION_INDEPENDENT_CODE ON) - -if(BUILD_TESTING) - add_subdirectory(test) -endif() - -if (BUILD_BENCHMARKS) - add_subdirectory(bench) -endif() diff --git a/src/libs/som/bench/CMakeLists.txt b/src/libs/som/bench/CMakeLists.txt deleted file mode 100644 index ccbb5bf8..00000000 --- a/src/libs/som/bench/CMakeLists.txt +++ /dev/null @@ -1,9 +0,0 @@ - -add_executable(bench-som - SomBench.cpp - ) - -target_link_libraries(bench-som PRIVATE - lmssom - benchmark - ) diff --git a/src/libs/som/bench/SomBench.cpp b/src/libs/som/bench/SomBench.cpp deleted file mode 100644 index 57181e74..00000000 --- a/src/libs/som/bench/SomBench.cpp +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2024 Emeric Poupon - * - * This file is part of LMS. - * - * LMS is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * LMS is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LMS. If not, see . - */ - -#include - -#include - -#include "som/Network.hpp" - -namespace lms::som -{ - // Benchmark function - static void BM_Matrix(benchmark::State& state) - { - std::minstd_rand randomEngine{ 42 }; - std::uniform_int_distribution distrib{ 0, 1000 }; - - Matrix matrix{ static_cast(state.range(0)), static_cast(state.range(0)) }; - - for (Coordinate x{}; x < matrix.getWidth(); ++x) - { - for (Coordinate y{}; y < matrix.getHeight(); ++y) - matrix.get({ x, y }) = distrib(randomEngine); - } - - for (auto _ : state) - { - // Code inside this loop is measured repeatedly - const Position pos{ matrix.getPositionMinElement([](int a, int b) { return a < b; }) }; - benchmark::DoNotOptimize(pos); - } - - // Perform cleanup here if needed - } - - // Register the benchmark with custom range - BENCHMARK(BM_Matrix)->Arg(3)->Arg(6)->Arg(12)->Arg(24); -} // namespace lms::som - -BENCHMARK_MAIN(); diff --git a/src/libs/som/impl/DataNormalizer.cpp b/src/libs/som/impl/DataNormalizer.cpp deleted file mode 100644 index ab2d82b0..00000000 --- a/src/libs/som/impl/DataNormalizer.cpp +++ /dev/null @@ -1,109 +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 . - */ - -#include "som/DataNormalizer.hpp" - -#include -#include -#include - -namespace lms::som -{ - template - static T variance(const std::vector& vec) - { - std::size_t size{ vec.size() }; - - if (size == 1) - return T{}; - - const T mean{ std::accumulate(vec.begin(), vec.end(), T{}) / size }; - - return std::accumulate(vec.begin(), vec.end(), T{}, - [mean, size](T accumulator, const T& val) { - return accumulator + ((val - mean) * (val - mean) / (size - 1)); - }); - } - - DataNormalizer::DataNormalizer(std::size_t inputDimCount) - : _inputDimCount{ inputDimCount } - { - } - - const DataNormalizer::MinMax& DataNormalizer::getValue(std::size_t index) const - { - return _minmax[index]; - } - - void DataNormalizer::setValue(std::size_t index, const MinMax& minMax) - { - _minmax[index] = minMax; - } - - void DataNormalizer::computeNormalizationFactors(const std::vector& inputVectors) - { - if (inputVectors.empty()) - throw Exception("Empty input vectors"); - - // For each dimension of the input, compute the min/max - _minmax.clear(); - _minmax.resize(_inputDimCount); - - for (std::size_t dimId{}; dimId < _inputDimCount; ++dimId) - { - std::vector values; - - for (const auto& inputVector : inputVectors) - { - checkSameDimensions(inputVector, _inputDimCount); - values.push_back(inputVector[dimId]); - } - - auto result{ std::minmax_element(values.begin(), values.end()) }; - _minmax[dimId] = { *result.first, *result.second }; - } - } - - InputVector::value_type DataNormalizer::normalizeValue(InputVector::value_type value, std::size_t dimId) const - { - // clamp - if (value > _minmax[dimId].max) - value = _minmax[dimId].max; - else if (value < _minmax[dimId].min) - value = _minmax[dimId].min; - - return (value - _minmax[dimId].min) / (_minmax[dimId].max - _minmax[dimId].min); - } - - void DataNormalizer::normalizeData(InputVector& a) const - { - checkSameDimensions(a, _inputDimCount); - - for (std::size_t dimId{}; dimId < _inputDimCount; ++dimId) - { - a[dimId] = normalizeValue(a[dimId], dimId); - } - } - - void DataNormalizer::dump(std::ostream& os) const - { - for (std::size_t i{}; i < _inputDimCount; ++i) - os << "(" << _minmax[i].min << ", " << _minmax[i].max << ")"; - } -} // namespace lms::som diff --git a/src/libs/som/impl/Network.cpp b/src/libs/som/impl/Network.cpp deleted file mode 100644 index f70dbb0a..00000000 --- a/src/libs/som/impl/Network.cpp +++ /dev/null @@ -1,300 +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 . - */ - -#include "som/Network.hpp" - -#include -#include -#include -#include -#include -#include - -#include "core/ILogger.hpp" -#include "core/Random.hpp" - -namespace lms::som -{ - void checkSameDimensions(const InputVector& a, const InputVector& b) - { - if (!a.hasSameDimension(b)) - throw Exception("Bad data dimension count"); - } - - void checkSameDimensions(const InputVector& a, std::size_t inputDimCount) - { - if (a.getNbDimensions() != inputDimCount) - throw Exception("Bad data dimension count"); - } - - static LearningFactor defaultLearningFactor(Network::CurrentIteration iteration) - { - static const LearningFactor initialValue{ 1 }; - - return initialValue * exp(-((iteration.idIteration + 1) / static_cast(iteration.iterationCount))); - } - - static InputVector::Distance euclidianSquareDistance(const InputVector& a, const InputVector& b, const InputVector& weights) - { - return a.computeEuclidianSquareDistance(b, weights); - } - - static InputVector::value_type sigmaFunc(Network::CurrentIteration iteration) - { - constexpr InputVector::value_type sigma0{ 1 }; - - return sigma0 * std::exp(-((iteration.idIteration + 1) / static_cast(iteration.iterationCount))); - } - - static InputVector::value_type defaultNeighbourhoodFunc(Norm norm, const Network::CurrentIteration& iteration) - { - InputVector::value_type sigma{ sigmaFunc(iteration) }; - - return exp(-norm / (2 * sigma * sigma)); - } - - Network::Network(Coordinate width, Coordinate height, std::size_t inputDimCount) - : _inputDimCount{ inputDimCount } - , _weights{ inputDimCount, static_cast(1) } - , _refVectors{ width, height, _inputDimCount } - , _distanceFunc{ euclidianSquareDistance } - , _learningFactorFunc{ defaultLearningFactor } - , _neighbourhoodFunc{ defaultNeighbourhoodFunc } - { - // init each vector with a random normalized value - for (Coordinate y{}; y < _refVectors.getHeight(); ++y) - { - for (Coordinate x{}; x < _refVectors.getWidth(); ++x) - { - for (InputVector::value_type& val : _refVectors.get({ x, y })) - val = core::random::getRealRandom(0, 1); - } - } - } - - void Network::setDataWeights(const InputVector& weights) - { - checkSameDimensions(weights, _inputDimCount); - - _weights = weights; - } - - void Network::setRefVector(const Position& position, const InputVector& data) - { - checkSameDimensions(data, _inputDimCount); - - _refVectors[position] = data; - } - - InputVector::Distance Network::getRefVectorsDistance(const Position& position1, const Position& position2) const - { - return _distanceFunc(_refVectors.get(position1), _refVectors.get(position2), _weights); - } - - InputVector::Distance Network::computeRefVectorsDistanceMean() const - { - std::vector values; - values.reserve(2 * _refVectors.getHeight() * _refVectors.getWidth() - _refVectors.getWidth() - _refVectors.getHeight()); - for (Coordinate y{}; y < _refVectors.getHeight(); ++y) - { - for (Coordinate x{}; x < _refVectors.getWidth(); ++x) - { - if (x != _refVectors.getWidth() - 1) - values.emplace_back(getRefVectorsDistance({ x, y }, { x + 1, y })); - if (y != _refVectors.getHeight() - 1) - values.emplace_back(getRefVectorsDistance({ x, y }, { x, y + 1 })); - } - } - - return std::accumulate(values.begin(), values.end(), 0.) / values.size(); - } - - double Network::computeRefVectorsDistanceMedian() const - { - std::vector values; - values.reserve(2 * _refVectors.getHeight() * _refVectors.getWidth() - _refVectors.getWidth() - _refVectors.getHeight()); - for (Coordinate y{}; y < _refVectors.getHeight(); ++y) - { - for (Coordinate x{}; x < _refVectors.getWidth(); ++x) - { - if (x != _refVectors.getWidth() - 1) - values.emplace_back(getRefVectorsDistance({ x, y }, { x + 1, y })); - if (y != _refVectors.getHeight() - 1) - values.emplace_back(getRefVectorsDistance({ x, y }, { x, y + 1 })); - } - } - - std::sort(values.begin(), values.end()); - - return values[values.size() > 1 ? values.size() / 2 - 1 : 0]; - } - - void Network::dump(std::ostream& os) const - { - os << "Width: " << _refVectors.getWidth() << ", Height: " << _refVectors.getHeight() << std::endl; - ; - - for (Coordinate y{}; y < _refVectors.getHeight(); ++y) - { - for (Coordinate x{}; x < _refVectors.getWidth(); ++x) - { - os << _refVectors.get({ x, y }) << " "; - } - - os << std::endl; - } - os << std::endl; - } - - Position Network::getClosestRefVectorPosition(const InputVector& data) const - { - return _refVectors.getPositionMinElement([&](const auto& a, const auto& b) { - return (_distanceFunc(a, data, _weights) < _distanceFunc(b, data, _weights)); - }); - } - - std::optional Network::getClosestRefVectorPosition(const InputVector& data, InputVector::Distance maxDistance) const - { - std::optional position{ getClosestRefVectorPosition(data) }; - - if (_distanceFunc(data, _refVectors.get(*position), _weights) > maxDistance) - position.reset(); - - return position; - } - - std::optional Network::getClosestRefVectorPosition(const std::vector& refVectorsPosition, InputVector::Distance maxDistance) const - { - std::unordered_set neighboursPosition; - for (const Position& refVectorPosition : refVectorsPosition) - { - if (refVectorPosition.y > 0) - neighboursPosition.insert({ refVectorPosition.x, refVectorPosition.y - 1 }); - if (refVectorPosition.y < _refVectors.getHeight() - 1) - neighboursPosition.insert({ refVectorPosition.x, refVectorPosition.y + 1 }); - if (refVectorPosition.x > 0) - neighboursPosition.insert({ refVectorPosition.x - 1, refVectorPosition.y }); - if (refVectorPosition.x < _refVectors.getWidth() - 1) - neighboursPosition.insert({ refVectorPosition.x + 1, refVectorPosition.y }); - } - - // remove position that are in the input position - for (const auto& refVectorPosition : refVectorsPosition) - neighboursPosition.erase(refVectorPosition); - - if (neighboursPosition.empty()) - return std::nullopt; - - // Now compute the distance for each neighbour - struct NeighbourInfo - { - Position position; - double distance; - }; - - std::vector neighboursInfo; - for (const Position& neighbourPosition : neighboursPosition) - { - auto min = std::min_element(refVectorsPosition.begin(), refVectorsPosition.end(), - [this, neighbourPosition](const auto& a, const auto& b) { - return (this->getRefVectorsDistance(a, neighbourPosition) < this->getRefVectorsDistance(b, neighbourPosition)); - }); - - InputVector::Distance distance{ getRefVectorsDistance(neighbourPosition, *min) }; - if (distance > maxDistance) - continue; - - neighboursInfo.emplace_back(NeighbourInfo{ neighbourPosition, distance }); - } - - if (neighboursInfo.empty()) - return std::nullopt; - - auto min{ std::min_element(std::cbegin(neighboursInfo), std::cend(neighboursInfo), - [&](const auto& a, const auto& b) { - return a.distance < b.distance; - }) }; - - return min->position; - } - - static Norm computePositionNorm(const Position& c1, const Position& c2) - { - return std::sqrt((c1.x - c2.x) * (c1.x - c2.x) + (c1.y - c2.y) * (c1.y - c2.y)); - } - - void Network::updateRefVectors(const Position& closestRefVectorPosition, const InputVector& input, LearningFactor learningFactor, const CurrentIteration& iteration) - { - for (Coordinate y{}; y < _refVectors.getHeight(); ++y) - { - for (Coordinate x{}; x < _refVectors.getWidth(); ++x) - { - InputVector& refVector{ _refVectors.get({ x, y }) }; - - const Norm norm{ computePositionNorm({ x, y }, closestRefVectorPosition) }; - - InputVector delta{ input - refVector }; - delta *= (learningFactor * _neighbourhoodFunc(norm, iteration)); - - refVector += delta; - } - } - } - - void Network::train(const std::vector& inputData, std::size_t nbIterations, ProgressCallback progressCallback, RequestStopCallback requestStopCallback) - { - bool stopRequested{ false }; - std::vector inputDataShuffled; - - inputDataShuffled.reserve(inputData.size()); - for (const auto& input : inputData) - inputDataShuffled.push_back(&input); - - for (std::size_t i{}; i < nbIterations; ++i) - { - CurrentIteration curIter{ i, nbIterations }; - - if (progressCallback) - progressCallback(curIter); - - core::random::shuffleContainer(inputDataShuffled); - - const LearningFactor learningFactor{ _learningFactorFunc(curIter) }; - - for (const InputVector* input : inputDataShuffled) - { - if (requestStopCallback) - stopRequested = requestStopCallback(); - - if (stopRequested) - return; - - updateRefVectors(getClosestRefVectorPosition(*input), *input, learningFactor, curIter); - } - - if (stopRequested) - return; - } - } - - const InputVector& Network::getRefVector(const Position& position) const - { - return _refVectors[position]; - } -} // namespace lms::som diff --git a/src/libs/som/include/som/DataNormalizer.hpp b/src/libs/som/include/som/DataNormalizer.hpp deleted file mode 100644 index 29ee83c4..00000000 --- a/src/libs/som/include/som/DataNormalizer.hpp +++ /dev/null @@ -1,58 +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 . - */ - -#pragma once - -#include -#include - -#include "Network.hpp" - -namespace lms::som -{ - class DataNormalizer - { - public: - struct MinMax - { - InputVector::value_type min; - InputVector::value_type max; - }; - - DataNormalizer(std::size_t inputDimCount); - - std::size_t getInputDimCount() const { return _inputDimCount; } - const MinMax& getValue(std::size_t index) const; - - void setValue(std::size_t index, const MinMax& minMax); - - void computeNormalizationFactors(const std::vector& dataSamples); - - void normalizeData(InputVector& data) const; - - void dump(std::ostream& os) const; - - private: - InputVector::value_type normalizeValue(InputVector::value_type value, std::size_t dimensionId) const; - - const std::size_t _inputDimCount; - - std::vector _minmax; // Indexed min/max used to normalize data - }; -} // namespace lms::som diff --git a/src/libs/som/include/som/InputVector.hpp b/src/libs/som/include/som/InputVector.hpp deleted file mode 100644 index ced4b2e4..00000000 --- a/src/libs/som/include/som/InputVector.hpp +++ /dev/null @@ -1,193 +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 . - */ - -#pragma once - -#include -#include - -#include "core/Exception.hpp" - -namespace lms::som -{ - class Exception : public core::LmsException - { - public: - using LmsException::LmsException; - }; - - class InputVector - { - public: - using value_type = double; - using Norm = double; - using Distance = double; - - InputVector(std::size_t nbDimensions, value_type defaultValue = value_type{}) - : _values(nbDimensions, defaultValue) {} - - bool hasSameDimension(const InputVector& other) const - { - return _values.size() == other._values.size(); - } - - std::size_t getNbDimensions() const - { - return _values.size(); - } - - value_type& operator[](std::size_t index) - { - if (index >= getNbDimensions()) - throw Exception("Bad range"); - - return _values[index]; - } - - value_type operator[](std::size_t index) const - { - if (index >= getNbDimensions()) - throw Exception("Bad range"); - - return _values[index]; - } - - InputVector& operator+=(const InputVector& other) - { - if (!hasSameDimension(other.getNbDimensions())) - throw Exception{ "Not the same dimension count" }; - - for (std::size_t i{}; i < _values.size(); ++i) - { - _values[i] += other[i]; - } - - return *this; - } - - InputVector& operator-=(const InputVector& other) - { - if (!hasSameDimension(other.getNbDimensions())) - throw Exception{ "Not the same dimension count" }; - - for (std::size_t i{}; i < _values.size(); ++i) - { - _values[i] -= other[i]; - } - - return *this; - } - - InputVector& operator*=(value_type factor) - { - for (std::size_t i{}; i < _values.size(); ++i) - { - _values[i] *= factor; - } - - return *this; - } - - Norm computeNorm() const - { - Norm res{}; - for (value_type val : _values) - res += val * val; - return std::sqrt(res); - } - - Distance computeEuclidianSquareDistance(const InputVector& other, const InputVector& weights) const - { - if (!hasSameDimension(other.getNbDimensions()) - || !hasSameDimension(weights.getNbDimensions())) - { - throw Exception{ "Not the same dimension count" }; - } - - Distance res{}; - - for (std::size_t i{}; i < getNbDimensions(); ++i) - { - const InputVector::value_type diff{ _values[i] - other._values[i] }; - res += diff * diff * weights._values[i]; - } - - return res; - } - - std::vector::iterator begin() - { - return _values.begin(); - } - - std::vector::const_iterator begin() const - { - return _values.cbegin(); - } - - std::vector::const_iterator cbegin() const - { - return _values.cbegin(); - } - - std::vector::iterator end() - { - return _values.end(); - } - - std::vector::const_iterator end() const - { - return _values.cend(); - } - - std::vector::const_iterator cend() const - { - return _values.cend(); - } - - private: - friend class InputVector operator-(const InputVector& a, const InputVector& b) - { - if (!a.hasSameDimension(b.getNbDimensions())) - throw Exception{ "Not the same dimension count" }; - - InputVector res{ a.getNbDimensions() }; - - for (std::size_t i{}; i < res._values.size(); ++i) - res._values[i] = a._values[i] - b._values[i]; - - return res; - } - - friend std::ostream& operator<<(std::ostream& os, const InputVector& a) - { - os << "["; - for (value_type val : a._values) - { - os << val << " "; - } - os << "]"; - - return os; - } - - std::vector _values; - }; -} // namespace lms::som diff --git a/src/libs/som/include/som/Matrix.hpp b/src/libs/som/include/som/Matrix.hpp deleted file mode 100644 index 83d8c34e..00000000 --- a/src/libs/som/include/som/Matrix.hpp +++ /dev/null @@ -1,128 +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 . - */ - -#pragma once - -#include -#include -#include -#include - -namespace lms::som -{ - using Coordinate = unsigned; - - struct Position - { - Coordinate x; - Coordinate y; - - bool operator<(const Position& other) const - { - if (x == other.x) - return y < other.y; - else - return x < other.x; - } - - bool operator==(const Position& other) const - { - return x == other.x && y == other.y; - } - }; - - template - class Matrix - { - public: - Matrix() = default; - - Matrix(Coordinate width, Coordinate height) - : _width{ width } - , _height{ height } - { - _values.resize(static_cast(_width) * static_cast(_height)); - } - - template - Matrix(Coordinate width, Coordinate height, CtrArgs&&... args) - : _width{ width } - , _height{ height } - { - _values.resize(static_cast(_width) * static_cast(_height), T{ std::forward(args)... }); - } - - void clear() - { - _values.clear(); - } - - Coordinate getHeight() const { return _height; } - Coordinate getWidth() const { return _width; } - - T& get(const Position& position) - { - assert(position.x < _width); - assert(position.y < _height); - return _values[position.x + _width * position.y]; - } - - const T& get(const Position& position) const - { - assert(position.x < _width); - assert(position.y < _height); - return _values[position.x + _width * position.y]; - } - - T& operator[](const Position& position) { return get(position); } - const T& operator[](const Position& position) const { return get(position); } - - template - Position getPositionMinElement(Func func) const - { - assert(!_values.empty()); - - const auto it{ std::min_element(_values.begin(), _values.end(), std::move(func)) }; - const auto index{ static_cast(std::distance(_values.begin(), it)) }; - - return Position{ index % _height, index / _height }; - } - - private: - Coordinate _width{}; - Coordinate _height{}; - std::vector _values; - }; - -} // namespace lms::som - -namespace std -{ - template<> - class hash - { - public: - size_t operator()(const lms::som::Position& s) const - { - size_t h1 = std::hash()(s.x); - size_t h2 = std::hash()(s.y); - return h1 ^ (h2 << 1); - } - }; -} // namespace std diff --git a/src/libs/som/include/som/Network.hpp b/src/libs/som/include/som/Network.hpp deleted file mode 100644 index 2bb28901..00000000 --- a/src/libs/som/include/som/Network.hpp +++ /dev/null @@ -1,104 +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 . - */ - -#pragma once - -#include -#include -#include -#include - -#include "InputVector.hpp" -#include "Matrix.hpp" - -namespace lms::som -{ - using LearningFactor = InputVector::value_type; - using Norm = InputVector::value_type; - - void checkSameDimensions(const InputVector& a, const InputVector& b); - void checkSameDimensions(const InputVector& a, std::size_t inputDimCount); - std::ostream& operator<<(std::ostream& os, const InputVector& a); - - class Network - { - public: - // Init a network with random values - Network(Coordinate width, Coordinate height, std::size_t inputDimCount); - - Coordinate getWidth() const { return _refVectors.getWidth(); } - Coordinate getHeight() const { return _refVectors.getHeight(); } - std::size_t getInputDimCount() const { return _inputDimCount; } - const InputVector& getDataWeights() const { return _weights; } - - // Set weight for each dimension (default is 1 for each weight) - void setDataWeights(const InputVector& weights); - - // use this to manually construct a network without training - void setRefVector(const Position& position, const InputVector& data); - - // data must be normalized - struct CurrentIteration - { - std::size_t idIteration; - std::size_t iterationCount; - }; - using ProgressCallback = std::function; - using RequestStopCallback = std::function; - void train(const std::vector& dataSamples, std::size_t nbIterations, ProgressCallback = ProgressCallback{}, RequestStopCallback = RequestStopCallback{}); - - const InputVector& getRefVector(const Position& position) const; - Position getClosestRefVectorPosition(const InputVector& data) const; - std::optional getClosestRefVectorPosition(const InputVector& data, InputVector::Distance maxDistance) const; - - std::optional getClosestRefVectorPosition(const std::vector& refVectorsPosition, InputVector::Distance maxDistance) const; - - InputVector::Distance getRefVectorsDistance(const Position& position1, const Position& position2) const; - - InputVector::Distance computeRefVectorsDistanceMean() const; - InputVector::Distance computeRefVectorsDistanceMedian() const; - - void dump(std::ostream& os) const; - - // For each ref vector, update formula is: - // i is the current iteration - // refVector(i+1) = refVector(i) + LearningFactor(i) * NeighbourhoodFunc(i) * (MatchingRefVector - refVector) - - using DistanceFunc = std::function; - void setDistanceFunc(DistanceFunc distanceFunc); - DistanceFunc getDistanceFunc() { return _distanceFunc; } - - using LearningFactorFunc = std::function; - void setLearningFactorFunc(LearningFactorFunc learningFactorFunc); - - using NeighbourhoodFunc = std::function; - void setNeighbourhoodFunc(NeighbourhoodFunc neighbourhoodFunc); - - private: - void updateRefVectors(const Position& closestRefVectorPosition, const InputVector& input, LearningFactor learningFactor, const CurrentIteration& iteration); - - std::size_t _inputDimCount{}; - InputVector _weights; // weight for each dimension - Matrix _refVectors; - - DistanceFunc _distanceFunc; - LearningFactorFunc _learningFactorFunc; - NeighbourhoodFunc _neighbourhoodFunc; - }; -} // namespace lms::som diff --git a/src/libs/som/test/CMakeLists.txt b/src/libs/som/test/CMakeLists.txt deleted file mode 100644 index 7781e77d..00000000 --- a/src/libs/som/test/CMakeLists.txt +++ /dev/null @@ -1,15 +0,0 @@ -include(GoogleTest) - -add_executable(test-som - SomTest.cpp - ) - -target_link_libraries(test-som PRIVATE - lmssom - GTest::GTest - ) - -if (NOT CMAKE_CROSSCOMPILING) - gtest_discover_tests(test-som) -endif() - diff --git a/src/libs/som/test/SomTest.cpp b/src/libs/som/test/SomTest.cpp deleted file mode 100644 index a6c98f76..00000000 --- a/src/libs/som/test/SomTest.cpp +++ /dev/null @@ -1,135 +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 . - */ - -#include - -#include - -#include "som/DataNormalizer.hpp" -#include "som/Network.hpp" - -namespace lms::som -{ - static constexpr InputVector::value_type EPSILON = 0.01; - - TEST(som, Matrix) - { - { - Matrix testMatrix{ 2, 2, 123 }; - { - const Position pos{ 0, 0 }; - EXPECT_EQ(testMatrix[pos], 123); - } - { - const Position pos{ 0, 1 }; - EXPECT_EQ(testMatrix[pos], 123); - } - { - const Position pos{ 1, 0 }; - EXPECT_EQ(testMatrix[pos], 123); - } - { - const Position pos{ 1, 1 }; - EXPECT_EQ(testMatrix[pos], 123); - } - } - } - - TEST(som, InputVector) - { - { - InputVector test1{ 2 }; - test1[0] = 0; - test1[1] = 1; - - InputVector test2{ 2 }; - test2[0] = 1; - test2[1] = 0; - - InputVector test3{ test1 }; - test3 += test2; - EXPECT_LT(std::abs(test3[0] - 1), EPSILON); - EXPECT_LT(std::abs(test3[1] - 1), EPSILON); - } - } - - TEST(som, Network) - { - Network network{ 2, 2, 1 }; - - const InputVector weights{ 1, 1 }; - std::vector trainData{ - { 1, 50 }, - { 1, 100 }, - { 1, 150 }, - { 1, 200 }, - }; - - DataNormalizer normalizer{ 1 }; - normalizer.computeNormalizationFactors(trainData); - for (auto& data : trainData) - normalizer.normalizeData(data); - - network.dump(std::cout); - network.train(trainData, 20); - network.dump(std::cout); - - auto distFunc{ network.getDistanceFunc() }; - - EXPECT_LT((std::abs(distFunc({ 1, 0 }, { 1, 1 }, weights) - 1)), EPSILON); - EXPECT_LT((std::abs(distFunc({ 1, 0 }, { 1, 2 }, weights) - 4)), EPSILON); - EXPECT_LT(std::abs(distFunc({ 1, 0 }, { 1, 0.33 }, weights) - distFunc({ 1, 0.66 }, { 1, 1. }, weights)), EPSILON); - - { - std::unordered_set positions; - for (const InputVector& data : trainData) - positions.insert(network.getClosestRefVectorPosition(data)); - - EXPECT_EQ(positions.size(), 4); - } - - { - Position pos{ network.getClosestRefVectorPosition(InputVector{ 1, 0.66 }) }; - for (std::size_t i{}; i < 40; ++i) - { - InputVector input{ 1, 130 + static_cast(i) }; - normalizer.normalizeData(input); - - EXPECT_EQ(network.getClosestRefVectorPosition(input), pos); - } - } - - { - Position pos{ network.getClosestRefVectorPosition(InputVector{ 1, 1 }) }; - for (std::size_t i{}; i < 40; ++i) - { - InputVector input{ 1, 180 + static_cast(i) }; - normalizer.normalizeData(input); - - EXPECT_EQ(network.getClosestRefVectorPosition(input), pos); - } - } - } -} // namespace lms::som - -int main(int argc, char** argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 21df8265..33910a8c 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -153,6 +153,8 @@ namespace lms::api::subsonic { "/getSimilarSongs", { handleGetSimilarSongsRequest } }, { "/getSimilarSongs2", { handleGetSimilarSongs2Request } }, { "/getTopSongs", { handleGetTopSongs } }, + { "/getSonicSimilarTracks", { handleGetSonicSimilarTracksRequest } }, + { "/findSonicPath", { handleFindSonicPathRequest } }, // Album/song lists { "/getAlbumList", { handleGetAlbumListRequest } }, diff --git a/src/libs/subsonic/impl/endpoints/Browsing.cpp b/src/libs/subsonic/impl/endpoints/Browsing.cpp index 59d17a86..0180043f 100644 --- a/src/libs/subsonic/impl/endpoints/Browsing.cpp +++ b/src/libs/subsonic/impl/endpoints/Browsing.cpp @@ -19,6 +19,9 @@ #include "Browsing.hpp" +#include +#include + #include "core/ILogger.hpp" #include "core/Random.hpp" #include "core/Service.hpp" @@ -109,7 +112,12 @@ namespace lms::api::subsonic { // API says: "Returns a random collection of songs from the given artist and similar artists" const std::size_t similarArtistCount{ count / 5 }; - std::vector artistIds{ core::Service::get()->getSimilarArtists(artistId, { TrackArtistLinkType::Artist }, similarArtistCount) }; + const recommendation::ArtistResults similarArtists{ core::Service::get()->findSimilarArtists(artistId, { TrackArtistLinkType::Artist }, similarArtistCount) }; + std::vector artistIds; + artistIds.reserve(similarArtists.size() + 1); + std::transform(std::cbegin(similarArtists), std::cend(similarArtists), std::back_inserter(artistIds), [](const auto& result) { + return result.id; + }); artistIds.push_back(artistId); const std::size_t meanTrackCountPerArtist{ (count / artistIds.size()) + 1 }; @@ -140,7 +148,12 @@ namespace lms::api::subsonic // API says: "Returns a random collection of songs from the given artist and similar artists" // so let's extend this for release const std::size_t similarReleaseCount{ count / 5 }; - std::vector releaseIds{ core::Service::get()->getSimilarReleases(releaseId, similarReleaseCount) }; + const recommendation::ReleaseResults similarReleases{ core::Service::get()->findSimilarReleases(releaseId, similarReleaseCount) }; + std::vector releaseIds; + releaseIds.reserve(similarReleases.size() + 1); + std::transform(std::cbegin(similarReleases), std::cend(similarReleases), std::back_inserter(releaseIds), [](const auto& result) { + return result.id; + }); releaseIds.push_back(releaseId); const std::size_t meanTrackCountPerRelease{ (count / releaseIds.size()) + 1 }; @@ -168,7 +181,14 @@ namespace lms::api::subsonic std::vector findSimilarSongs(RequestContext& /*context*/, TrackId trackId, std::size_t count) { - return core::Service::get()->findSimilarTracks({ trackId }, count); + const std::array trackIdSpan{ trackId }; + const recommendation::TrackResults similarTracks{ core::Service::get()->findSimilarTracks(trackIdSpan, count) }; + std::vector trackIds; + trackIds.reserve(similarTracks.size()); + std::transform(std::cbegin(similarTracks), std::cend(similarTracks), std::back_inserter(trackIds), [](const auto& result) { + return result.id; + }); + return trackIds; } Response handleGetSimilarSongsRequestCommon(RequestContext& context, bool id3) @@ -565,16 +585,16 @@ namespace lms::api::subsonic }); } - auto similarArtistsId{ core::Service::get()->getSimilarArtists(id, { TrackArtistLinkType::Artist }, count) }; + auto similarArtists{ core::Service::get()->findSimilarArtists(id, { TrackArtistLinkType::Artist }, count) }; { auto transaction{ context.getDbSession().createReadTransaction() }; - for (const ArtistId similarArtistId : similarArtistsId) + for (const auto& similarArtist : similarArtists) { - const Artist::pointer similarArtist{ Artist::find(context.getDbSession(), similarArtistId) }; - if (similarArtist) - artistInfoNode.addArrayChild("similarArtist", createArtistNode(context, similarArtist)); + const Artist::pointer artist{ Artist::find(context.getDbSession(), similarArtist.id) }; + if (artist) + artistInfoNode.addArrayChild("similarArtist", createArtistNode(context, artist)); } } @@ -653,4 +673,65 @@ namespace lms::api::subsonic return response; } + + Response handleGetSonicSimilarTracksRequest(RequestContext& context) + { + // Mandatory params + const auto trackId{ getMandatoryParameterAs(context.getParameters(), "id") }; + + // Optional params + std::size_t count{ getParameterAs(context.getParameters(), "count").value_or(10) }; + if (count > defaultMaxCountSize) + throw ParameterValueTooHighGenericError{ "count", defaultMaxCountSize }; + + const auto similarTracks{ core::Service::get()->findSimilarTracks(std::span{ &trackId, 1 }, count) }; + + auto transaction{ context.getDbSession().createReadTransaction() }; + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + + for (const auto& similarTrack : similarTracks) + { + const Track::pointer track{ Track::find(context.getDbSession(), similarTrack.id) }; + if (track) + { + Response::Node& sonicMatchNode{ response.createArrayNode("sonicMatch") }; + sonicMatchNode.setAttribute("similarity", 1.0F - similarTrack.distance); + sonicMatchNode.addChild("entry", createSongNode(context, track, context.getUser())); + } + } + + return response; + } + + Response handleFindSonicPathRequest(RequestContext& context) + { + // Mandatory params + const auto startTrackId{ getMandatoryParameterAs(context.getParameters(), "startSongId") }; + const auto endTrackId{ getMandatoryParameterAs(context.getParameters(), "endSongId") }; + + // Optional params + std::size_t count{ getParameterAs(context.getParameters(), "count").value_or(25) }; + if (count > defaultMaxCountSize) + throw ParameterValueTooHighGenericError{ "count", defaultMaxCountSize }; + + const auto pathTracks{ core::Service::get()->findTrackSimilarityPath(startTrackId, endTrackId, count) }; + + auto transaction{ context.getDbSession().createReadTransaction() }; + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + + for (const auto& pathTrack : pathTracks) + { + const Track::pointer track{ Track::find(context.getDbSession(), pathTrack.id) }; + if (track) + { + Response::Node& sonicMatchNode{ response.createArrayNode("sonicMatch") }; + sonicMatchNode.setAttribute("similarity", 1.0F - pathTrack.distance); + sonicMatchNode.addChild("entry", createSongNode(context, track, context.getUser())); + } + } + + return response; + } } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/endpoints/Browsing.hpp b/src/libs/subsonic/impl/endpoints/Browsing.hpp index c27ad595..39a5416d 100644 --- a/src/libs/subsonic/impl/endpoints/Browsing.hpp +++ b/src/libs/subsonic/impl/endpoints/Browsing.hpp @@ -38,4 +38,6 @@ namespace lms::api::subsonic Response handleGetSimilarSongsRequest(RequestContext& context); Response handleGetSimilarSongs2Request(RequestContext& context); Response handleGetTopSongs(RequestContext& context); + Response handleGetSonicSimilarTracksRequest(RequestContext& context); + Response handleFindSonicPathRequest(RequestContext& context); } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/endpoints/System.cpp b/src/libs/subsonic/impl/endpoints/System.cpp index 9cc6eeee..987c60fa 100644 --- a/src/libs/subsonic/impl/endpoints/System.cpp +++ b/src/libs/subsonic/impl/endpoints/System.cpp @@ -58,6 +58,7 @@ namespace lms::api::subsonic Extension{ "songLyrics", 1 }, Extension{ "transcodeOffset", 1 }, Extension{ "transcoding", 1 }, + Extension{ "sonicSimilarity", 1 }, }; for (const Extension& extension : extensions) diff --git a/src/libs/subsonic/test/SubsonicResponse.cpp b/src/libs/subsonic/test/SubsonicResponse.cpp index a21523ab..1d3f8cb7 100644 --- a/src/libs/subsonic/test/SubsonicResponse.cpp +++ b/src/libs/subsonic/test/SubsonicResponse.cpp @@ -17,6 +17,7 @@ * along with LMS. If not, see . */ +#include #include #include @@ -113,4 +114,23 @@ namespace lms::api::subsonic::tests EXPECT_EQ(oss.str(), expected); } + TEST(SubsonicResponse, jsonNaNAndInfinity) + { + Response response{ Response::createOkResponse(defaultServerProtocolVersion) }; + + Response::Node& node{ response.createNode("MyMath") }; + node.setAttribute("finite", 1.25F); + node.setAttribute("nan", std::numeric_limits::quiet_NaN()); + node.setAttribute("negInf", -std::numeric_limits::infinity()); + node.setAttribute("posInf", std::numeric_limits::infinity()); + + std::ostringstream oss; + response.write(oss, ResponseFormat::json); + + std::string expected{ R"({"subsonic-response":{"openSubsonic":true,"serverVersion":"${VERSION}","status":"ok","type":"lms","version":"1.16.1","MyMath":{"finite":1.25,"nan":null,"negInf":null,"posInf":null}}})" }; + expected = core::stringUtils::replaceInString(expected, "${VERSION}", core::getVersion()); + + EXPECT_EQ(oss.str(), expected); + } + } // namespace lms::api::subsonic::tests diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 51f81923..a409b759 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -37,8 +37,8 @@ #include "audio/IAudioOutput.hpp" #include "database/IDb.hpp" -#include "database/IQueryPlanRecorder.hpp" #include "database/Session.hpp" +#include "database/profiling/IQueryProfiler.hpp" #include "image/Image.hpp" #include "services/artwork/IArtworkService.hpp" #include "services/auth/IAuthTokenService.hpp" @@ -47,7 +47,6 @@ #include "services/feedback/IFeedbackService.hpp" #include "services/jukebox//IJukeboxService.hpp" #include "services/podcast/IPodcastService.hpp" -#include "services/recommendation/IPlaylistGeneratorService.hpp" #include "services/recommendation/IRecommendationService.hpp" #include "services/scanner/IScannerService.hpp" #include "services/scrobbling/IScrobblingService.hpp" @@ -404,7 +403,7 @@ namespace lms const std::vector wtServerArgs{ generateWtConfig(argv[0]) }; std::vector wtArgv(wtServerArgs.size()); - for (std::size_t i = 0; i < wtServerArgs.size(); ++i) + for (std::size_t i{}; i < wtServerArgs.size(); ++i) { std::cout << "ARG = " << wtServerArgs[i] << std::endl; wtArgv[i] = wtServerArgs[i].c_str(); @@ -427,9 +426,9 @@ namespace lms boost::asio::io_context ioContext; // ioContext used to dispatch all the services that are out of the Wt event loop core::IOContextRunner ioContextRunner{ ioContext, getThreadCount(), "Misc" }; - core::Service queryPlanRecorder; - if (config->getBool("db-record-query-plans", false)) - queryPlanRecorder.assign(db::createQueryPlanRecorder()); + core::Service QueryProfiler; + if (config->getBool("db-profile-queries", false)) + QueryProfiler.assign(db::createQueryProfiler()); // Connection pool size must be twice the number of threads: we have at least 2 io pools with getThreadCount() each and they all may access the database auto database{ db::createDb(config->getPath("working-dir", "/var/lms") / "lms.db", getThreadCount() * 2) }; @@ -483,7 +482,6 @@ namespace lms image::init(argv[0]); core::Service artworkService{ artwork::createArtworkService(*database, server.appRoot() + "/images/unknown-cover.svg", server.appRoot() + "/images/unknown-artist.svg") }; core::Service recommendationService{ recommendation::createRecommendationService(*database) }; - core::Service playlistGeneratorService{ recommendation::createPlaylistGeneratorService(*database, *recommendationService) }; core::Service scannerService{ scanner::createScannerService(*database, cachePath) }; core::Service transcodingService{ transcoding::createTranscodeService() }; core::Service podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") }; @@ -491,10 +489,12 @@ namespace lms const auto jukeboxAudioBackend{ getJukeboxAudioOutputBackend() }; core::Service jukeboxService{ jukeboxAudioBackend ? jukebox::createJukeboxService(*database, *jukeboxAudioBackend) : nullptr }; - scannerService->getEvents().scanComplete.connect([&] { - // Flush cover cache even if no changes: - // covers may be external files that changed and we don't keep track of them for now (but we should) - artworkService->flushCache(); + scannerService->getEvents().scanComplete.connect([&](const scanner::ScanStats& stats) { + if (stats.getChangesCount() > 0) + artworkService->flushCache(); + + if (stats.featureExtractions > 0) + recommendationService->requestReload(); }); core::Service feedbackService{ feedback::createFeedbackService(ioContext, *database) }; diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 216f0a36..83b89728 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -32,13 +32,13 @@ #include "core/Service.hpp" #include "database/IDb.hpp" -#include "database/IQueryPlanRecorder.hpp" #include "database/Session.hpp" #include "database/objects/Artist.hpp" #include "database/objects/Cluster.hpp" #include "database/objects/Release.hpp" #include "database/objects/TrackList.hpp" #include "database/objects/User.hpp" +#include "database/profiling/IQueryProfiler.hpp" #include "services/artwork/IArtworkService.hpp" #include "services/auth/IAuthTokenService.hpp" #include "services/auth/IEnvService.hpp" @@ -480,7 +480,7 @@ namespace lms::ui navbar->bindNew("users", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/users" }, Wt::WString::tr("Lms.Admin.menu-users")); // Hide the entry if no debug service is enabled if (core::Service::get() - || core::Service::get()) + || core::Service::get()) { navbar->setCondition("if-debug-tools", true); navbar->bindNew("debug-tools", Wt::WLink{ Wt::LinkType::InternalPath, "/admin/debug-tools" }, Wt::WString::tr("Lms.Admin.menu-debug-tools")); diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index e4c214b5..9d478e89 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -40,7 +40,7 @@ #include "database/objects/TrackList.hpp" #include "database/objects/User.hpp" #include "services/feedback/IFeedbackService.hpp" -#include "services/recommendation/IPlaylistGeneratorService.hpp" +#include "services/recommendation/IRecommendationService.hpp" #include "LmsApplication.hpp" #include "MediaPlayer.hpp" @@ -610,7 +610,13 @@ namespace lms::ui void PlayQueue::enqueueRadioTracks() { - std::vector trackIds = core::Service::get()->extendPlaylist(_queueId, 15); + const recommendation::TrackResults results{ core::Service::get()->findSimilarTracks(_queueId, 15) }; + + std::vector trackIds; + trackIds.reserve(results.size()); + for (const auto& result : results) + trackIds.push_back(result.id); + enqueueTracks(trackIds); } diff --git a/src/lms/ui/admin/ScanSettingsView.cpp b/src/lms/ui/admin/ScanSettingsView.cpp index 020df4bc..93a4a824 100644 --- a/src/lms/ui/admin/ScanSettingsView.cpp +++ b/src/lms/ui/admin/ScanSettingsView.cpp @@ -44,6 +44,39 @@ namespace lms::ui { namespace { + using RecommendationEngineTypeModel = ValueStringModel; + + class RecommendationEngineValidator : public Wt::WValidator + { + public: + RecommendationEngineValidator(std::shared_ptr model) + : _model{ model } + { + } + + private: + Wt::WValidator::Result validate(const Wt::WString& input) const override + { + if (input.empty()) + return Wt::WValidator::validate(input); + + std::string inputStr{ input.toUTF8() }; + + const auto row{ _model->getRowFromString(inputStr) }; + if (row && _model->getValue(*row) == db::ScanSettings::RecommendationEngineType::AudioSimilarity) + { + if (!core::Service::get()->isEngineTypeSupported(recommendation::EngineType::AudioSimilarity)) + return Wt::WValidator::Result{ Wt::ValidationState::Invalid, Wt::WString::tr("Lms.Admin.Database.recommendation-engine-not-supported") }; + } + + return Wt::WValidator::Result{ Wt::ValidationState::Valid }; + } + + std::string javaScriptValidate() const override { return {}; } + + std::shared_ptr _model; + }; + class TagDelimitersValidator : public Wt::WValidator { private: @@ -67,7 +100,7 @@ namespace lms::ui public: static inline constexpr Field UpdatePeriodField{ "update-period" }; static inline constexpr Field UpdateStartTimeField{ "update-start-time" }; - static inline constexpr Field SimilarityEngineTypeField{ "similarity-engine-type" }; + static inline constexpr Field RecommendationEngineTypeField{ "recommendation-engine-type" }; static inline constexpr Field SkipSingleReleasePlayListsField{ "skip-single-release-playlists" }; static inline constexpr Field AllowMBIDArtistMergeField{ "allow-mbid-artist-merge" }; static inline constexpr Field ArtistImageFallbackToReleaseField{ "artist-image-fallback-to-release" }; @@ -81,7 +114,7 @@ namespace lms::ui addField(UpdatePeriodField); addField(UpdateStartTimeField); - addField(SimilarityEngineTypeField); + addField(RecommendationEngineTypeField); addField(SkipSingleReleasePlayListsField); addField(AllowMBIDArtistMergeField); addField(ArtistImageFallbackToReleaseField); @@ -89,7 +122,13 @@ namespace lms::ui setValidator(UpdatePeriodField, createMandatoryValidator()); setValidator(UpdateStartTimeField, createMandatoryValidator()); - setValidator(SimilarityEngineTypeField, createMandatoryValidator()); + + { + std::shared_ptr recommendationEngineValidator{ std::make_shared(_recommendationEngineTypeModel) }; + recommendationEngineValidator->setMandatory(true); + setValidator(RecommendationEngineTypeField, recommendationEngineValidator); + } + setValidator(SkipSingleReleasePlayListsField, createMandatoryValidator()); setValidator(AllowMBIDArtistMergeField, createMandatoryValidator()); setValidator(ArtistImageFallbackToReleaseField, createMandatoryValidator()); @@ -97,7 +136,7 @@ namespace lms::ui std::shared_ptr updatePeriodModel() { return _updatePeriodModel; } std::shared_ptr updateStartTimeModel() { return _updateStartTimeModel; } - std::shared_ptr similarityEngineTypeModel() { return _similarityEngineTypeModel; } + std::shared_ptr recommendationEngineTypeModel() { return _recommendationEngineTypeModel; } void loadData(std::vector& extraTagsToScan, std::vector& artistDelimiters, std::vector& defaultDelimiters) { @@ -123,9 +162,9 @@ namespace lms::ui setValue(AllowMBIDArtistMergeField, scanSettings->getAllowMBIDArtistMerge()); setValue(ArtistImageFallbackToReleaseField, scanSettings->getArtistImageFallbackToReleaseField()); - auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromValue(scanSettings->getSimilarityEngineType()) }; - if (similarityEngineTypeRow) - setValue(SimilarityEngineTypeField, _similarityEngineTypeModel->getString(*similarityEngineTypeRow)); + auto recommendationEngineTypeRow{ _recommendationEngineTypeModel->getRowFromValue(scanSettings->getRecommendationEngineType()) }; + if (recommendationEngineTypeRow) + setValue(RecommendationEngineTypeField, _recommendationEngineTypeModel->getString(*recommendationEngineTypeRow)); const auto extraTags{ scanSettings->getExtraTagsToScan() }; extraTagsToScan.clear(); @@ -175,9 +214,9 @@ namespace lms::ui } { - const auto similarityEngineTypeRow{ _similarityEngineTypeModel->getRowFromString(valueText(SimilarityEngineTypeField)) }; - if (similarityEngineTypeRow) - scanSettings.modify()->setSimilarityEngineType(_similarityEngineTypeModel->getValue(*similarityEngineTypeRow)); + const auto recommendationEngineTypeRow{ _recommendationEngineTypeModel->getRowFromString(valueText(RecommendationEngineTypeField)) }; + if (recommendationEngineTypeRow) + scanSettings.modify()->setRecommendationEngineType(_recommendationEngineTypeModel->getValue(*recommendationEngineTypeRow)); } scanSettings.modify()->setExtraTagsToScan(extraTagsToScan); @@ -192,8 +231,7 @@ namespace lms::ui } private: - void - initializeModels() + void initializeModels() { _updatePeriodModel = std::make_shared>(); _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.never"), db::ScanSettings::UpdatePeriod::Never); @@ -203,20 +241,21 @@ namespace lms::ui _updatePeriodModel->add(Wt::WString::tr("Lms.Admin.Database.monthly"), db::ScanSettings::UpdatePeriod::Monthly); _updateStartTimeModel = std::make_shared>(); - for (std::size_t i = 0; i < 24; ++i) + for (std::size_t i{}; i < 24; ++i) { Wt::WTime time{ static_cast(i), 0 }; _updateStartTimeModel->add(time.toString(), time); } - _similarityEngineTypeModel = std::make_shared>(); - _similarityEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.similarity-engine-type.clusters"), db::ScanSettings::SimilarityEngineType::Clusters); - _similarityEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.similarity-engine-type.none"), db::ScanSettings::SimilarityEngineType::None); + _recommendationEngineTypeModel = std::make_shared>(); + _recommendationEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.recommendation-engine-type.audio-similarity"), db::ScanSettings::RecommendationEngineType::AudioSimilarity); + _recommendationEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.recommendation-engine-type.clusters"), db::ScanSettings::RecommendationEngineType::Clusters); + _recommendationEngineTypeModel->add(Wt::WString::tr("Lms.Admin.Database.recommendation-engine-type.none"), db::ScanSettings::RecommendationEngineType::None); } std::shared_ptr _updatePeriodModel; std::shared_ptr> _updateStartTimeModel; - std::shared_ptr> _similarityEngineTypeModel; + std::shared_ptr _recommendationEngineTypeModel; }; class LineEditEntryModel : public Wt::WFormModel @@ -225,7 +264,6 @@ namespace lms::ui static inline constexpr Field ValueField{ "value" }; LineEditEntryModel(const Wt::WString& initialValue, std::shared_ptr validator) - : Wt::WFormModel() { addField(ValueField); @@ -381,10 +419,10 @@ namespace lms::ui // Allow to fallback on release image if artist image is not available t->setFormWidget(DatabaseSettingsModel::ArtistImageFallbackToReleaseField, std::make_unique()); - // Similarity engine type - auto similarityEngineType{ std::make_unique() }; - similarityEngineType->setModel(model->similarityEngineTypeModel()); - t->setFormWidget(DatabaseSettingsModel::SimilarityEngineTypeField, std::move(similarityEngineType)); + // Recommendation engine type + auto recommendationEngineType{ std::make_unique() }; + recommendationEngineType->setModel(model->recommendationEngineTypeModel()); + t->setFormWidget(DatabaseSettingsModel::RecommendationEngineTypeField, std::move(recommendationEngineType)); // Extra tags std::shared_ptr extraTagValidator{ createUppercaseValidator() }; @@ -490,7 +528,7 @@ namespace lms::ui model->saveData(extraTagViews, artistDelimiterViews, defaultDelimiterViews); - core::Service::get()->load(); + core::Service::get()->requestReload(); // Don't want the scanner to go on with wrong settings core::Service::get()->requestReload(); LmsApp->notifyMsg(Notification::Type::Info, Wt::WString::tr("Lms.settings-saved")); diff --git a/src/lms/ui/admin/ScannerController.cpp b/src/lms/ui/admin/ScannerController.cpp index 0db024de..b2830bd1 100644 --- a/src/lms/ui/admin/ScannerController.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -212,6 +212,13 @@ namespace lms::ui .arg(stepStats.progress())); break; + case ScanStep::ExtractMusicNNEmbeddings: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-extract-musicnn-embeddings") + .arg(stepStats.processedElems) + .arg(stepStats.totalElems) + .arg(stepStats.progress())); + break; + case ScanStep::Optimize: _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-optimize") .arg(stepStats.progress())); @@ -227,8 +234,8 @@ namespace lms::ui .arg(stepStats.processedElems)); break; - case ScanStep::ReloadSimilarityEngine: - _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-reloading-similarity-engine") + case ScanStep::ReloadRecommendationEngine: + _stepStatus->setText(Wt::WString::tr("Lms.Admin.ScannerController.step-reloading-recommendation-engine") .arg(stepStats.progress())); break; diff --git a/src/lms/ui/admin/ScannerReportResource.cpp b/src/lms/ui/admin/ScannerReportResource.cpp index da29fc53..8a1c369a 100644 --- a/src/lms/ui/admin/ScannerReportResource.cpp +++ b/src/lms/ui/admin/ScannerReportResource.cpp @@ -93,6 +93,10 @@ namespace lms::ui { _os << error.path << ": " << Wt::WString::tr("Lms.Admin.ScannerController.playlist-all-pathes-missing").toUTF8() << '\n'; } + void visit(const scanner::MusicNNEmbeddingsExtractError& error) override + { + _os << error.path << ": " << Wt::WString::tr("Lms.Admin.ScannerController.cannot-extract-musicnn-embeddings").arg(Wt::WString::fromUTF8(error.errorMsg)).toUTF8() << '\n'; + } std::ostream& _os; }; diff --git a/src/lms/ui/admin/ScannerReportResource.hpp b/src/lms/ui/admin/ScannerReportResource.hpp index 7fa17458..f5e89158 100644 --- a/src/lms/ui/admin/ScannerReportResource.hpp +++ b/src/lms/ui/admin/ScannerReportResource.hpp @@ -24,7 +24,6 @@ namespace lms::ui { - class ScannerReportResource : public Wt::WResource { public: @@ -41,5 +40,4 @@ namespace lms::ui static Wt::WString duplicateReasonToWString(scanner::DuplicateReason reason); std::unique_ptr _stats; }; - } // namespace lms::ui diff --git a/src/lms/ui/admin/debug/Database.cpp b/src/lms/ui/admin/debug/Database.cpp index 811afb58..b87cc1d9 100644 --- a/src/lms/ui/admin/debug/Database.cpp +++ b/src/lms/ui/admin/debug/Database.cpp @@ -25,28 +25,29 @@ #include #include -#include "core/ITraceLogger.hpp" +#include "core/Service.hpp" #include "core/String.hpp" -#include "database/IQueryPlanRecorder.hpp" + +#include "database/profiling/IQueryProfiler.hpp" namespace lms::ui { namespace { - class QueryPlansReportResource : public Wt::WResource + class QueryProfilingReportResource : public Wt::WResource { public: - QueryPlansReportResource(const db::IQueryPlanRecorder& recorder) + QueryProfilingReportResource(const db::IQueryProfiler& recorder) : _recorder{ recorder } { } - ~QueryPlansReportResource() + ~QueryProfilingReportResource() { beingDeleted(); } - QueryPlansReportResource(const QueryPlansReportResource&) = delete; - QueryPlansReportResource& operator=(const QueryPlansReportResource&) = delete; + QueryProfilingReportResource(const QueryProfilingReportResource&) = delete; + QueryProfilingReportResource& operator=(const QueryProfilingReportResource&) = delete; private: void handleRequest(const Wt::Http::Request&, Wt::Http::Response& response) @@ -58,16 +59,20 @@ namespace lms::ui return fieldName + "*=UTF-8''" + Wt::Utils::urlEncode(fieldValue); }; - const std::string cdp{ encodeHttpHeaderField("filename", "LMS_db_query_plans_" + core::stringUtils::toISO8601String(Wt::WDateTime::currentDateTime()) + ".txt") }; + const std::string cdp{ encodeHttpHeaderField("filename", "LMS_db_query_profiling_" + core::stringUtils::toISO8601String(Wt::WDateTime::currentDateTime()) + ".txt") }; response.addHeader("Content-Disposition", "attachment; " + cdp); - _recorder.visitQueryPlans([&](std::string_view query, std::string_view plan) { - response.out() << query << '\n'; - response.out() << plan << "\n-------------------------\n"; + _recorder.visitQueries([&](const db::IQueryProfiler::QueryStats& stats) { + response.out() << stats.query << '\n'; + response.out() << "Calls: " << stats.callCount + << " | Total: " << stats.totalTime.count() << " µs" + << " | Mean: " << stats.meanTime.count() << " µs" + << " | StdDev: " << stats.stdDevTime.count() << " µs\n"; + response.out() << stats.plan << "\n-------------------------\n"; }); } - const db::IQueryPlanRecorder& _recorder; + const db::IQueryProfiler& _recorder; }; } // namespace @@ -76,11 +81,11 @@ namespace lms::ui { addFunction("tr", &Wt::WTemplate::Functions::tr); - Wt::WPushButton* dumpBtn{ bindNew("export-query-plans-btn", Wt::WString::tr("Lms.Admin.DebugTools.Db.export-query-plans")) }; + Wt::WPushButton* dumpBtn{ bindNew("export-query-profiling-btn", Wt::WString::tr("Lms.Admin.DebugTools.Db.export-query-profiling")) }; - if (const auto* recorder{ core::Service::get() }) + if (const auto* recorder{ core::Service::get() }) { - Wt::WLink link{ std::make_shared(*recorder) }; + Wt::WLink link{ std::make_shared(*recorder) }; link.setTarget(Wt::LinkTarget::NewWindow); dumpBtn->setLink(link); } diff --git a/src/lms/ui/common/MandatoryValidator.cpp b/src/lms/ui/common/MandatoryValidator.cpp index 06505183..02cce076 100644 --- a/src/lms/ui/common/MandatoryValidator.cpp +++ b/src/lms/ui/common/MandatoryValidator.cpp @@ -27,8 +27,7 @@ namespace lms::ui std::string javaScriptValidate() const override { return {}; } }; - std::unique_ptr - createMandatoryValidator() + std::unique_ptr createMandatoryValidator() { auto v{ std::make_unique() }; v->setMandatory(true); diff --git a/src/lms/ui/explore/ArtistView.cpp b/src/lms/ui/explore/ArtistView.cpp index c3073f1f..e1bea7e0 100644 --- a/src/lms/ui/explore/ArtistView.cpp +++ b/src/lms/ui/explore/ArtistView.cpp @@ -19,6 +19,7 @@ #include "ArtistView.hpp" +#include #include #include @@ -108,7 +109,12 @@ namespace lms::ui if (!artistId) throw ArtistNotFoundException{}; - const auto similarArtistIds{ core::Service::get()->getSimilarArtists(*artistId, { db::TrackArtistLinkType::Artist }, 6) }; + const auto similarArtists{ core::Service::get()->findSimilarArtists(*artistId, { db::TrackArtistLinkType::Artist }, 6) }; + std::vector similarArtistIds; + similarArtistIds.reserve(similarArtists.size()); + std::transform(std::cbegin(similarArtists), std::cend(similarArtists), std::back_inserter(similarArtistIds), [](const auto& result) { + return result.id; + }); auto transaction{ LmsApp->getDbSession().createReadTransaction() }; @@ -126,7 +132,7 @@ namespace lms::ui refreshAppearsOnReleases(); refreshNonReleaseTracks(); refreshLinks(artist); - refreshSimilarArtists(similarArtistIds); + refreshRelatedArtists(similarArtistIds); Wt::WContainerWidget* clusterContainers{ bindNew("clusters") }; @@ -363,13 +369,13 @@ namespace lms::ui setCondition("if-has-non-release-tracks", added); } - void Artist::refreshSimilarArtists(const std::vector& similarArtistsId) + void Artist::refreshRelatedArtists(const std::vector& similarArtistsId) { if (similarArtistsId.empty()) return; - setCondition("if-has-similar-artists", true); - Wt::WContainerWidget* similarArtistsContainer{ bindNew("similar-artists") }; + setCondition("if-has-related-artists", true); + Wt::WContainerWidget* similarArtistsContainer{ bindNew("related-artists") }; for (const db::ArtistId artistId : similarArtistsId) { diff --git a/src/lms/ui/explore/ArtistView.hpp b/src/lms/ui/explore/ArtistView.hpp index 47d37c63..82368919 100644 --- a/src/lms/ui/explore/ArtistView.hpp +++ b/src/lms/ui/explore/ArtistView.hpp @@ -58,7 +58,7 @@ namespace lms::ui void refreshReleases(); void refreshAppearsOnReleases(); void refreshNonReleaseTracks(); - void refreshSimilarArtists(const std::vector& similarArtistsId); + void refreshRelatedArtists(const std::vector& similarArtistsId); void refreshLinks(const db::ObjectPtr& artist); struct ReleaseContainer; diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index 268d69e8..9f1455d6 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -19,6 +19,7 @@ #include "ReleaseView.hpp" +#include #include #include @@ -231,7 +232,12 @@ namespace lms::ui if (!releaseId) throw ReleaseNotFoundException{}; - auto similarReleasesIds{ core::Service::get()->getSimilarReleases(*releaseId, 5) }; + const auto similarReleases{ core::Service::get()->findSimilarReleases(*releaseId, 5) }; + std::vector similarReleasesIds; + similarReleasesIds.reserve(similarReleases.size()); + std::transform(std::cbegin(similarReleases), std::cend(similarReleases), std::back_inserter(similarReleasesIds), [](const auto& result) { + return result.id; + }); auto& session{ LmsApp->getDbSession() }; auto transaction{ session.createReadTransaction() }; @@ -246,7 +252,7 @@ namespace lms::ui refreshCopyright(release); refreshLinks(release); refreshOtherVersions(release); - refreshSimilarReleases(similarReleasesIds); + refreshRelatedReleases(similarReleasesIds); bindString("name", Wt::WString::fromUTF8(std::string{ release->getName() }), Wt::TextFormat::Plain); if (std::string_view comment{ release->getComment() }; !comment.empty()) @@ -582,13 +588,13 @@ namespace lms::ui } } - void Release::refreshSimilarReleases(const std::vector& similarReleaseIds) + void Release::refreshRelatedReleases(const std::vector& similarReleaseIds) { if (similarReleaseIds.empty()) return; - setCondition("if-has-similar-releases", true); - auto* similarReleasesContainer{ bindNew("similar-releases") }; + setCondition("if-has-related-releases", true); + auto* similarReleasesContainer{ bindNew("related-releases") }; for (const db::ReleaseId id : similarReleaseIds) { diff --git a/src/lms/ui/explore/ReleaseView.hpp b/src/lms/ui/explore/ReleaseView.hpp index 76de048c..90f85946 100644 --- a/src/lms/ui/explore/ReleaseView.hpp +++ b/src/lms/ui/explore/ReleaseView.hpp @@ -50,7 +50,7 @@ namespace lms::ui void refreshCopyright(const db::ObjectPtr& release); void refreshLinks(const db::ObjectPtr& release); void refreshOtherVersions(const db::ObjectPtr& release); - void refreshSimilarReleases(const std::vector& similarReleaseIds); + void refreshRelatedReleases(const std::vector& similarReleaseIds); std::unique_ptr createDisc(const db::ObjectPtr& medium); diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index 0b1eeab8..c20c1f5f 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -1,4 +1,5 @@ add_subdirectory(audioinfo) add_subdirectory(audioplay) add_subdirectory(db-generator) -add_subdirectory(recommendation) +add_subdirectory(musicnn) +add_subdirectory(recommendation) \ No newline at end of file diff --git a/src/tools/musicnn-export/export_onnx.py b/src/tools/musicnn-export/export_onnx.py new file mode 100755 index 00000000..9727d111 --- /dev/null +++ b/src/tools/musicnn-export/export_onnx.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Export MusicNN (oriyonay/musicnn-pytorch) to ONNX, stopping at the 200-D embedding +(after fc1 + BN, before the fc2 classification head). + +Everything (model code + weights) is downloaded automatically from HuggingFace. +No git clone required. + +Input tensor : mel_patch [1, 187, 96] float32 + batch=1, T=187 frames, mel=96 bands + (log-mel spectrogram, pre-BN — bn_input is inside the graph) +Output tensor : embedding [1, 200] float32 + +Usage: + pip install torch huggingface_hub onnx onnxruntime + cd tools/musicnn + python export_onnx.py --model MSD_musicnn --output MSD_musicnn_embedding.onnx +""" + +import argparse +import importlib.util +import sys +from pathlib import Path + +import torch +import torch.nn as nn +import torch.nn.functional as F + +HF_REPO = "oriyonay/musicnn-pytorch" +HF_REVISION = "394be17b3a5c2c2e1bb8a6593cfc3c5557eb8a82" + +# --------------------------------------------------------------------------- +# Download musicnn_torch.py from HuggingFace if needed +# --------------------------------------------------------------------------- +def _import_musicnn_torch() -> type: + """Import MusicNN from the HF repo's musicnn_torch.py (downloaded on demand). + + musicnn_torch.py imports librosa and soundfile at module level, but those are + only needed by the audio-loading helpers (batch_data, extractor, top_tags). + We stub them out so the import succeeds without those optional dependencies. + """ + import sys + import types + + try: + from huggingface_hub import hf_hub_download + except ImportError: + print("ERROR: huggingface_hub not installed. Run: pip install huggingface_hub", file=sys.stderr) + sys.exit(1) + + # Stub out optional audio-loading dependencies that musicnn_torch.py imports + # at module level but that we don't need for model-architecture access. + for mod_name in ("librosa", "librosa.feature", "soundfile"): + if mod_name not in sys.modules: + sys.modules[mod_name] = types.ModuleType(mod_name) + + local_py = hf_hub_download(repo_id=HF_REPO, filename="musicnn_torch.py", revision=HF_REVISION) + spec = importlib.util.spec_from_file_location("musicnn_torch", local_py) + module = importlib.util.module_from_spec(spec) # type: ignore[arg-type] + spec.loader.exec_module(module) # type: ignore[union-attr] + return module.MusicNN + + +# --------------------------------------------------------------------------- +# Wrapper: runs the full MusicNN forward up to the 200-D embedding. +# +# MusicNN.forward() from musicnn_torch.py (abridged): +# x = x.unsqueeze(1) -- adds channel dim: [B, 1, T, mel] +# x = bn_input(x) +# frontend_features = cat([f74, f77, s1, s2, s3], dim=2) -- [B, T, 561] +# mid_feats = midend(...) -- list of 4 tensors +# z = cat(mid_feats, dim=2) -- [B, T, 753] +# logits, mean_pool, max_pool = backend(z) +# backend: max_pool + mean_pool interleaved via stack+view -- [B, 1506] +# bn_in -> fc1 -> relu -> bn_fc1 -> fc2 +# We stop before fc2 and return bn_fc1 output. +# --------------------------------------------------------------------------- +class MusicNNEmbeddingWrapper(nn.Module): + def __init__(self, model: nn.Module) -> None: + super().__init__() + self.model = model + + def forward(self, x: torch.Tensor) -> torch.Tensor: + # x: [batch, T=187, mel=96] (same as musicnn_torch extractor input) + m = self.model + + # Replicate MusicNN.forward() up to the embedding + h = x.unsqueeze(1) # [B, 1, T, mel] + h = m.bn_input(h) + f74 = m.timbral_1(h).transpose(1, 2) + f77 = m.timbral_2(h).transpose(1, 2) + s1 = m.temp_1(h).transpose(1, 2) + s2 = m.temp_2(h).transpose(1, 2) + s3 = m.temp_3(h).transpose(1, 2) + frontend = torch.cat([f74, f77, s1, s2, s3], dim=2) # [B, T, 561] + mid_feats = m.midend(frontend.transpose(1, 2)) # list of 4 tensors + z = torch.cat(mid_feats, dim=2) # [B, T, 753] + + # Backend — replicate exactly, stopping before fc2 + be = m.backend + max_pool = torch.max(z, dim=1).values # [B, 753] + mean_pool = torch.mean(z, dim=1) # [B, 753] + # musicnn_torch uses stack+view to interleave, NOT cat + pooled = torch.stack([max_pool, mean_pool], dim=2) # [B, 753, 2] + pooled = pooled.view(pooled.size(0), -1) # [B, 1506] interleaved + pooled = be.bn_in(pooled) + pooled = F.relu(be.fc1(pooled)) + embedding = be.bn_fc1(pooled) # [B, 200] + return embedding + + +def main() -> None: + parser = argparse.ArgumentParser(description="Export MusicNN to ONNX (embedding output)") + parser.add_argument("--model", default="MSD_musicnn", + choices=["MTT_musicnn", "MSD_musicnn"], + help="Which checkpoint to download from HuggingFace (default: MSD_musicnn)") + parser.add_argument("--output", default="MSD_musicnn_embedding.onnx", + help="Output ONNX file path (default: MSD_musicnn_embedding.onnx)") + parser.add_argument("--opset", type=int, default=17, + help="ONNX opset version (default: 17)") + args = parser.parse_args() + + print("Importing MusicNN architecture from HuggingFace ...", file=sys.stderr) + MusicNN = _import_musicnn_torch() + + try: + from huggingface_hub import hf_hub_download + except ImportError: + print("ERROR: huggingface_hub not installed. Run: pip install huggingface_hub", file=sys.stderr) + sys.exit(1) + hf_path = f"weights/{args.model}.pt" + print(f"Downloading {hf_path} from {HF_REPO} ...", file=sys.stderr) + local_path = hf_hub_download(repo_id=HF_REPO, filename=hf_path, revision=HF_REVISION) + sd = torch.load(local_path, map_location="cpu", weights_only=True) + + # num_classes=50 for both MTT and MSD standard models + model = MusicNN(num_classes=50) + model.load_state_dict(sd) + model.eval() + + wrapper = MusicNNEmbeddingWrapper(model) + wrapper.eval() + + # Input: [batch=1, T=187, mel=96] + dummy = torch.zeros(1, 187, 96, dtype=torch.float32) + + with torch.no_grad(): + ref_out = wrapper(dummy) + print(f"Reference output shape : {ref_out.shape}", file=sys.stderr) + print(f"Reference output range : [{ref_out.min().item():.4f}, {ref_out.max().item():.4f}]", file=sys.stderr) + + output_path = Path(args.output) + print(f"Exporting to {output_path} (opset={args.opset}) ...", file=sys.stderr) + + torch.onnx.export( + wrapper, + dummy, + str(output_path), + input_names=["mel_patch"], + output_names=["embedding"], + dynamic_axes=None, # fixed shape: batch=1 always + opset_version=args.opset, + do_constant_folding=True, + dynamo=False, # force legacy exporter (no onnxscript dependency) + ) + + size_mb = output_path.stat().st_size / 1024 / 1024 + print(f"Done. {output_path} ({size_mb:.2f} MB)", file=sys.stderr) + + # Quick round-trip check with onnxruntime if available + try: + import numpy as np + import onnxruntime as ort + sess = ort.InferenceSession(str(output_path), providers=["CPUExecutionProvider"]) + ort_out = sess.run(["embedding"], {"mel_patch": dummy.numpy()})[0] + max_diff = float(abs(ref_out.numpy() - ort_out).max()) + print(f"ORT round-trip max diff vs PyTorch : {max_diff:.2e}", file=sys.stderr) + if max_diff > 1e-4: + print("WARNING: large diff — check the model graph", file=sys.stderr) + else: + print("Round-trip OK.", file=sys.stderr) + except ImportError: + print("onnxruntime not installed; skipping round-trip check.", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/tools/musicnn/CMakeLists.txt b/src/tools/musicnn/CMakeLists.txt new file mode 100644 index 00000000..4fa2fb39 --- /dev/null +++ b/src/tools/musicnn/CMakeLists.txt @@ -0,0 +1,9 @@ +add_executable(lms-musicnn + LmsMusicNN.cpp + ) + +target_link_libraries(lms-musicnn PRIVATE + lmsaudio + lmscore + Boost::program_options + ) diff --git a/src/tools/musicnn/LmsMusicNN.cpp b/src/tools/musicnn/LmsMusicNN.cpp new file mode 100644 index 00000000..3ff9cbcb --- /dev/null +++ b/src/tools/musicnn/LmsMusicNN.cpp @@ -0,0 +1,96 @@ +/* + * 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 . + */ + +#include + +#include + +#include "audio/Exception.hpp" +#include "audio/IMusicNNEmbeddingExtractor.hpp" +#include "core/ILogger.hpp" + +int main(int argc, char* argv[]) +{ + try + { + using namespace lms; + namespace program_options = boost::program_options; + + program_options::options_description options{ "Options" }; + // clang-format off + options.add_options() + ("help,h", "Display this help message") + ("model,m", program_options::value()->required(), "Path to the MusicNN model file") + ("input,i", program_options::value()->required(), "Input audio file path") + ("max-patch-count,p", program_options::value()->default_value(20), "Max non-overlapping patches (more = better accuracy but slower, must be > 0)"); + // clang-format on + + program_options::variables_map vm; + program_options::store(program_options::parse_command_line(argc, argv, options), vm); + + if (vm.count("help")) + { + std::cout << options << "\n"; + return EXIT_SUCCESS; + } + + program_options::notify(vm); + + const std::filesystem::path modelPath{ vm["model"].as() }; + const std::filesystem::path inputPath{ vm["input"].as() }; + + if (!std::filesystem::exists(modelPath)) + throw std::runtime_error{ "Model file '" + modelPath.string() + "' does not exist!" }; + if (!std::filesystem::exists(inputPath)) + throw std::runtime_error{ "Input file '" + inputPath.string() + "' does not exist!" }; + + core::Service logger{ core::logging::createLogger(core::logging::Severity::WARNING) }; + + try + { + const unsigned maxPatchCount{ vm["max-patch-count"].as() }; + + const auto extractor{ audio::createMusicNNEmbeddingExtractor(modelPath, maxPatchCount) }; + const auto result{ extractor->extract(inputPath) }; + + std::cout << "patch_count=" << result.patchCount << "\n"; + + std::cout << "mean="; + for (std::size_t i{}; i < audio::MusicNNEmbedding::size; ++i) + { + if (i > 0) + std::cout << ','; + std::cout << result.embeddings.mean.values[i]; + } + std::cout << "\n"; + } + catch (const audio::Exception& e) + { + std::cerr << "Audio error: " << e.what() << "\n"; + return EXIT_FAILURE; + } + } + catch (const std::exception& e) + { + std::cerr << "Error: " << e.what() << "\n"; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} diff --git a/src/tools/recommendation/CMakeLists.txt b/src/tools/recommendation/CMakeLists.txt index a4923a32..f42ed504 100644 --- a/src/tools/recommendation/CMakeLists.txt +++ b/src/tools/recommendation/CMakeLists.txt @@ -8,3 +8,5 @@ target_link_libraries(lms-recommendation PRIVATE lmsrecommendation Boost::program_options ) + + install(TARGETS lms-recommendation DESTINATION bin) diff --git a/src/tools/recommendation/LmsRecommendation.cpp b/src/tools/recommendation/LmsRecommendation.cpp index 20e89f26..f24b7a42 100644 --- a/src/tools/recommendation/LmsRecommendation.cpp +++ b/src/tools/recommendation/LmsRecommendation.cpp @@ -17,16 +17,21 @@ * along with LMS. If not, see . */ +#include +#include #include #include -#include +#include +#include #include #include "core/IConfig.hpp" #include "core/ILogger.hpp" #include "core/Service.hpp" +#include "core/String.hpp" #include "core/SystemPaths.hpp" +#include "core/UUID.hpp" #include "database/IDb.hpp" #include "database/Session.hpp" #include "database/Types.hpp" @@ -34,88 +39,405 @@ #include "database/objects/Cluster.hpp" #include "database/objects/Release.hpp" #include "database/objects/Track.hpp" +#include "database/objects/TrackMusicNNEmbeddings.hpp" #include "services/recommendation/IRecommendationService.hpp" namespace lms { - using namespace db; - - void dumpTracksRecommendation(Session session, recommendation::IRecommendationService& recommendationService, unsigned maxSimilarityCount) + void dumpTracksRecommendation(db::Session session, recommendation::IRecommendationService& recommendationService, std::string_view name, unsigned maxCount) { - const RangeResults trackIds{ [&] { - auto transaction{ session.createReadTransaction() }; - return Track::findIds(session, Track::FindParameters{}); - }() }; + std::vector trackIds; - std::cout << "*** Tracks (" << trackIds.results.size() << ") ***" << std::endl; - for (const TrackId trackId : trackIds.results) + if (const auto mbid{ core::UUID::fromString(name) }) { - auto trackToString = [&](const TrackId trackId) { - std::string res; + auto transaction{ session.createReadTransaction() }; + for (const auto& track : db::Track::findByMBID(session, *mbid)) + trackIds.push_back(track->getId()); + } + else + { + db::Track::FindParameters params; + params.setKeywords(core::stringUtils::splitString(name, ' ')); + + auto transaction{ session.createReadTransaction() }; + trackIds = db::Track::findIds(session, params).results; + } + + std::cout << "*** Tracks (" << trackIds.size() << ") ***" << std::endl; + for (const db::TrackId trackId : trackIds) + { + auto trackToString = [&](const db::TrackId trackId) { auto transaction{ session.createReadTransaction() }; - const Track::pointer track{ Track::find(session, trackId) }; + const db::Track::pointer track{ db::Track::find(session, trackId) }; - res += track->getName(); + std::ostringstream oss; + oss << "'" << track->getName() << "'"; if (track->getRelease()) - res += " [" + std::string{ track->getRelease()->getName() } + "]"; - for (const auto& artist : track->getArtists({ TrackArtistLinkType::Artist })) - res += " - " + artist->getName(); + oss << " [" << track->getRelease()->getName() << "]"; + if (std::string_view artistDisplayName{ track->getArtistDisplayName() }; !artistDisplayName.empty()) + oss << " by '" << artistDisplayName << "'"; for (const auto& cluster : track->getClusters()) - res += " {" + std::string{ cluster->getType()->getName() } + "-" + std::string{ cluster->getName() } + "}"; + oss << " {" << cluster->getType()->getName() << "-" << cluster->getName() << "}"; + oss << " - '" + track->getAbsoluteFilePath().string() << "'"; - return res; + return oss.str(); }; - std::cout << "Processing track '" << trackToString(trackId) << std::endl; - for (TrackId similarTrackId : recommendationService.findSimilarTracks({ trackId }, maxSimilarityCount)) - std::cout << "\t- Similar track '" << trackToString(similarTrackId) << std::endl; + std::cout << "Processing track " << trackToString(trackId) << std::endl; + + for (const auto& similarTrack : recommendationService.findSimilarTracks(std::span{ &trackId, 1 }, maxCount)) + std::cout << "\t- " << similarTrack.distance << ", Similar track " << trackToString(similarTrack.id) << std::endl; } } - void dumpReleasesRecommendation(Session session, recommendation::IRecommendationService& recommendationService, unsigned maxSimilarityCount) + void dumpReleasesRecommendation(db::Session session, recommendation::IRecommendationService& recommendationService, std::string_view name, unsigned maxCount) { - const RangeResults releaseIds{ std::invoke([&] { + std::vector releaseIds; + + if (const auto mbid{ core::UUID::fromString(name) }) + { auto transaction{ session.createReadTransaction() }; - return Release::findIds(session, Release::FindParameters{}); - }) }; + if (const auto release{ db::Release::find(session, *mbid) }) + releaseIds.push_back(release->getId()); + } + else + { + db::Release::FindParameters params; + params.setKeywords(core::stringUtils::splitString(name, ' ')); + + auto transaction{ session.createReadTransaction() }; + releaseIds = db::Release::findIds(session, params).results; + } std::cout << "*** Releases ***" << std::endl; - for (const ReleaseId releaseId : releaseIds.results) + for (const db::ReleaseId releaseId : releaseIds) { - auto releaseToString = [&](ReleaseId releaseId) -> std::string { + auto releaseToString = [&](db::ReleaseId releaseId) -> std::string { auto transaction{ session.createReadTransaction() }; - Release::pointer release{ Release::find(session, releaseId) }; - return std::string{ release->getName() }; + const db::Release::pointer release{ db::Release::find(session, releaseId) }; + + std::ostringstream oss; + + oss << "'" << release->getName() << "'"; + if (std::string_view artistDisplayName{ release->getArtistDisplayName() }; !artistDisplayName.empty()) + oss << " by '" << artistDisplayName << "'"; + + return oss.str(); }; std::cout << "Processing release '" << releaseToString(releaseId) << "'" << std::endl; - for (const ReleaseId similarReleaseId : recommendationService.getSimilarReleases(releaseId, maxSimilarityCount)) - std::cout << "\t- Similar release '" << releaseToString(similarReleaseId) << "'" << std::endl; + for (const auto& similarRelease : recommendationService.findSimilarReleases(releaseId, maxCount)) + std::cout << "\t- " << similarRelease.distance << ", Similar release " << releaseToString(similarRelease.id) << std::endl; } } - void dumpArtistsRecommendation(Session session, recommendation::IRecommendationService& recommendationService, unsigned maxSimilarityCount) + void dumpArtistsRecommendation(db::Session session, recommendation::IRecommendationService& recommendationService, std::string_view name, unsigned maxCount) { - const RangeResults artistIds = std::invoke([&]() { + std::vector artistIds; + + if (const auto mbid{ core::UUID::fromString(name) }) + { auto transaction{ session.createReadTransaction() }; - return Artist::findIds(session, Artist::FindParameters{}); - }); + if (const auto artist{ db::Artist::find(session, *mbid) }) + artistIds.push_back(artist->getId()); + } + else + { + db::Artist::FindParameters params; + params.setKeywords(core::stringUtils::splitString(name, ' ')); + + auto transaction{ session.createReadTransaction() }; + artistIds = db::Artist::findIds(session, params).results; + } std::cout << "*** Artists ***" << std::endl; - for (ArtistId artistId : artistIds.results) + for (db::ArtistId artistId : artistIds) { - auto artistToString = [&](ArtistId artistId) { + auto artistToString = [&](db::ArtistId artistId) { auto transaction{ session.createReadTransaction() }; - Artist::pointer artist{ Artist::find(session, artistId) }; + db::Artist::pointer artist{ db::Artist::find(session, artistId) }; return artist->getName(); }; std::cout << "Processing artist '" << artistToString(artistId) << "'" << std::endl; - for (ArtistId similarArtistId : recommendationService.getSimilarArtists(artistId, { TrackArtistLinkType::Artist }, maxSimilarityCount)) + for (const auto& similarArtist : recommendationService.findSimilarArtists(artistId, { db::TrackArtistLinkType::Artist }, maxCount)) + std::cout << "\t- " << similarArtist.distance << ", Similar artist '" << artistToString(similarArtist.id) << "'" << std::endl; + } + } + + void dumpRandomTracksRecommendation( + db::Session session, + recommendation::IRecommendationService& recommendationService, + std::size_t randomTrackCount, + unsigned seed, + unsigned maxCount) + { + if (randomTrackCount == 0) + return; + + std::vector trackIds; + { + auto transaction{ session.createReadTransaction() }; + trackIds.reserve(db::TrackMusicNNEmbeddings::getCount(session)); + db::TrackMusicNNEmbeddings::find(session, [&](const db::TrackMusicNNEmbeddings::pointer& features) { + trackIds.push_back(features->getTrackId()); + }); + } + + if (trackIds.empty()) + { + std::cout << "No tracks with TrackMusicNNEmbeddings found" << std::endl; + return; + } + + const unsigned effectiveSeed{ seed != 0 ? seed : std::random_device{}() }; + std::minstd_rand rng{ effectiveSeed }; + std::shuffle(std::begin(trackIds), std::end(trackIds), rng); + + const std::size_t queryCount{ std::min(randomTrackCount, trackIds.size()) }; + + auto trackToString = [&](const db::TrackId trackId) { + auto transaction{ session.createReadTransaction() }; + const db::Track::pointer track{ db::Track::find(session, trackId) }; + + std::ostringstream oss; + oss << "'" << track->getName() << "'"; + if (track->getRelease()) + oss << " [" << track->getRelease()->getName() << "]"; + if (std::string_view artistDisplayName{ track->getArtistDisplayName() }; !artistDisplayName.empty()) + oss << " by '" << artistDisplayName << "'"; + + return oss.str(); + }; + + std::cout << "*** Random Tracks Baseline ***" << std::endl; + std::cout << "seed=" << effectiveSeed << ", count=" << queryCount << ", max=" << maxCount << std::endl; + + for (std::size_t i{}; i < queryCount; ++i) + { + const db::TrackId trackId{ trackIds[i] }; + + std::cout << "Query " << (i + 1) << ": " << trackToString(trackId) << std::endl; + const auto similarTracks{ recommendationService.findSimilarTracks(std::span{ &trackId, 1 }, maxCount) }; + + if (similarTracks.empty()) { - std::cout << "\t- Similar artist '" << artistToString(similarArtistId) << "'" << std::endl; + std::cout << "\t- no similar tracks" << std::endl; + continue; + } + + for (const auto& similarTrack : similarTracks) + std::cout << "\t- " << similarTrack.distance << ", Similar track " << trackToString(similarTrack.id) << std::endl; + } + } + + void dumpRandomReleasesRecommendation( + db::Session session, + recommendation::IRecommendationService& recommendationService, + std::size_t randomReleaseCount, + unsigned seed, + unsigned maxCount) + { + if (randomReleaseCount == 0) + return; + + std::vector releaseIds; + { + auto transaction{ session.createReadTransaction() }; + releaseIds = db::Release::findIds(session, db::Release::FindParameters{}).results; + } + + if (releaseIds.empty()) + { + std::cout << "No releases found" << std::endl; + return; + } + + const unsigned effectiveSeed{ seed != 0 ? seed : std::random_device{}() }; + std::minstd_rand rng{ effectiveSeed }; + std::shuffle(std::begin(releaseIds), std::end(releaseIds), rng); + + const std::size_t queryCount{ std::min(randomReleaseCount, releaseIds.size()) }; + + auto releaseToString = [&](const db::ReleaseId releaseId) -> std::string { + auto transaction{ session.createReadTransaction() }; + const db::Release::pointer release{ db::Release::find(session, releaseId) }; + + std::ostringstream oss; + oss << "'" << release->getName() << "'"; + if (std::string_view artistDisplayName{ release->getArtistDisplayName() }; !artistDisplayName.empty()) + oss << " by '" << artistDisplayName << "'"; + + return oss.str(); + }; + + std::cout << "*** Random Releases Baseline ***" << std::endl; + std::cout << "seed=" << effectiveSeed << ", count=" << queryCount << ", max=" << maxCount << std::endl; + + for (std::size_t i{}; i < queryCount; ++i) + { + const db::ReleaseId releaseId{ releaseIds[i] }; + + std::cout << "Query " << (i + 1) << ": " << releaseToString(releaseId) << std::endl; + const auto similarReleases{ recommendationService.findSimilarReleases(releaseId, maxCount) }; + + if (similarReleases.empty()) + { + std::cout << "\t- no similar releases" << std::endl; + continue; + } + + for (const auto& similarRelease : similarReleases) + std::cout << "\t- " << similarRelease.distance << ", Similar release " << releaseToString(similarRelease.id) << std::endl; + } + } + + void dumpRandomArtistsRecommendation( + db::Session session, + recommendation::IRecommendationService& recommendationService, + std::size_t randomArtistCount, + unsigned seed, + unsigned maxCount) + { + if (randomArtistCount == 0) + return; + + std::vector artistIds; + { + auto transaction{ session.createReadTransaction() }; + artistIds = db::Artist::findIds(session, db::Artist::FindParameters{}).results; + } + + if (artistIds.empty()) + { + std::cout << "No artists found" << std::endl; + return; + } + + const unsigned effectiveSeed{ seed != 0 ? seed : std::random_device{}() }; + std::minstd_rand rng{ effectiveSeed }; + std::shuffle(std::begin(artistIds), std::end(artistIds), rng); + + const std::size_t queryCount{ std::min(randomArtistCount, artistIds.size()) }; + + auto artistToString = [&](const db::ArtistId artistId) { + auto transaction{ session.createReadTransaction() }; + const db::Artist::pointer artist{ db::Artist::find(session, artistId) }; + return artist->getName(); + }; + + std::cout << "*** Random Artists Baseline ***" << std::endl; + std::cout << "seed=" << effectiveSeed << ", count=" << queryCount << ", max=" << maxCount << std::endl; + + for (std::size_t i{}; i < queryCount; ++i) + { + const db::ArtistId artistId{ artistIds[i] }; + + std::cout << "Query " << (i + 1) << ": '" << artistToString(artistId) << "'" << std::endl; + const auto similarArtists{ recommendationService.findSimilarArtists(artistId, { db::TrackArtistLinkType::Artist }, maxCount) }; + + if (similarArtists.empty()) + { + std::cout << "\t- no similar artists" << std::endl; + continue; + } + + for (const auto& similarArtist : similarArtists) + std::cout << "\t- " << similarArtist.distance << ", Similar artist '" << artistToString(similarArtist.id) << "'" << std::endl; + } + } + + void dumpTrackPaths( + db::Session session, + recommendation::IRecommendationService& recommendationService, + std::string_view fromName, + std::string_view toName, + unsigned maxCount) + { + std::vector fromTrackIds; + std::vector toTrackIds; + + // Find 'from' tracks + if (const auto mbid{ core::UUID::fromString(fromName) }) + { + auto transaction{ session.createReadTransaction() }; + for (const auto& track : db::Track::findByMBID(session, *mbid)) + fromTrackIds.push_back(track->getId()); + } + else + { + db::Track::FindParameters params; + params.setKeywords(core::stringUtils::splitString(fromName, ' ')); + + auto transaction{ session.createReadTransaction() }; + fromTrackIds = db::Track::findIds(session, params).results; + } + + // Find 'to' tracks + if (const auto mbid{ core::UUID::fromString(toName) }) + { + auto transaction{ session.createReadTransaction() }; + for (const auto& track : db::Track::findByMBID(session, *mbid)) + toTrackIds.push_back(track->getId()); + } + else + { + db::Track::FindParameters params; + params.setKeywords(core::stringUtils::splitString(toName, ' ')); + + auto transaction{ session.createReadTransaction() }; + toTrackIds = db::Track::findIds(session, params).results; + } + + if (fromTrackIds.empty() || toTrackIds.empty()) + { + std::cout << "*** Track Paths ***" << std::endl; + std::cout << "No matching tracks found" << std::endl; + return; + } + + auto trackToString = [&](const db::TrackId trackId) { + auto transaction{ session.createReadTransaction() }; + const db::Track::pointer track{ db::Track::find(session, trackId) }; + + std::ostringstream oss; + oss << "'" << track->getName() << "'"; + if (track->getRelease()) + oss << " [" << track->getRelease()->getName() << "]"; + if (std::string_view artistDisplayName{ track->getArtistDisplayName() }; !artistDisplayName.empty()) + oss << " by '" << artistDisplayName << "'"; + + return oss.str(); + }; + + std::cout << "*** Track Paths ***" << std::endl; + std::cout << "From (" << fromTrackIds.size() << ") To (" << toTrackIds.size() << ") - max path length " << maxCount << std::endl; + + std::size_t pathCount{}; + for (const auto& fromTrackId : fromTrackIds) + { + for (const auto& toTrackId : toTrackIds) + { + ++pathCount; + std::cout << std::endl + << "Path " << pathCount << ": "; + std::cout << trackToString(fromTrackId) << " => " << trackToString(toTrackId) << std::endl; + + const auto path{ recommendationService.findTrackSimilarityPath(fromTrackId, toTrackId, maxCount) }; + + if (path.empty()) + { + std::cout << "\t(no path found)" << std::endl; + continue; + } + + for (std::size_t i{}; i < path.size(); ++i) + { + const auto& result{ path[i] }; + std::cout << "\t" << (i + 1) << ". " << trackToString(result.id) << " (distance: " << result.distance << ")" << std::endl; + } } } } @@ -129,10 +451,24 @@ int main(int argc, char* argv[]) namespace po = boost::program_options; // log to stdout - core::Service logger{ core::logging::createLogger() }; + core::Service logger{ core::logging::createLogger(core::logging::Severity::DEBUG) }; po::options_description desc{ "Allowed options" }; - desc.add_options()("help,h", "print usage message")("conf,c", po::value()->default_value(core::sysconfDirectory / "lms.conf"), "LMS config file")("artists,a", "Display recommendation for artists")("releases,r", "Display recommendation for releases")("tracks,t", "Display recommendation for tracks")("max,m", po::value()->default_value(3), "Max similarity result count"); + // clang-format off + desc.add_options() + ("help,h", "print usage message") + ("conf,c", po::value()->default_value(core::sysconfDirectory / "lms.conf"), "LMS config file") + ("artist,a", po::value(), "Display recommendation for a given artist (mbid or name search pattern)") + ("release,r", po::value(), "Display recommendation for releases (mbid or name search pattern)") + ("track,t", po::value(), "Display recommendation for tracks (track mbid or name search pattern)") + ("track-path-from", po::value(), "Find similarity path from track (mbid or name search pattern)") + ("track-path-to", po::value(), "Find similarity path to track (mbid or name search pattern)") + ("random-tracks", po::value(), "Display recommendation for N random tracks") + ("random-releases", po::value(), "Display recommendation for N random releases") + ("random-artists", po::value(), "Display recommendation for N random artists") + ("seed", po::value()->default_value(0), "Seed used with --random-tracks/--random-releases/--random-artists (0 means random seed)") + ("max,m", po::value()->default_value(10), "Max recommendation result count"); + // clang-format on po::variables_map vm; po::store(po::parse_command_line(argc, argv, desc), vm); @@ -146,27 +482,41 @@ int main(int argc, char* argv[]) core::Service config{ core::createConfig(vm["conf"].as()) }; auto db{ db::createDb(config->getPath("working-dir", "/var/lms") / "lms.db") }; - Session session{ *db }; + db::Session session{ *db }; - std::cout << "Creating recommendation service..." << std::endl; const auto recommendationService{ recommendation::createRecommendationService(*db) }; - std::cout << "Recommendation service created!" << std::endl; - std::cout << "Loading recommendation service..." << std::endl; - recommendationService->load(); + if (recommendationService->getEngineType() == recommendation::EngineType::None) + { + std::cout << "Recommendation engine is disabled" << std::endl; + return EXIT_SUCCESS; + } - unsigned maxSimilarityCount{ vm["max"].as() }; + while (!recommendationService->isLoaded()) + std::this_thread::sleep_for(std::chrono::milliseconds{ 100 }); - std::cout << "Recommendation service loaded!" << std::endl; + unsigned maxCount{ vm["max"].as() }; - if (vm.count("tracks")) - dumpTracksRecommendation(*db, *recommendationService, maxSimilarityCount); + if (vm.count("track")) + dumpTracksRecommendation(*db, *recommendationService, vm["track"].as(), maxCount); - if (vm.count("releases")) - dumpReleasesRecommendation(*db, *recommendationService, maxSimilarityCount); + if (vm.count("release")) + dumpReleasesRecommendation(*db, *recommendationService, vm["release"].as(), maxCount); - if (vm.count("artists")) - dumpArtistsRecommendation(*db, *recommendationService, maxSimilarityCount); + if (vm.count("artist")) + dumpArtistsRecommendation(*db, *recommendationService, vm["artist"].as(), maxCount); + + if (vm.count("track-path-from") && vm.count("track-path-to")) + dumpTrackPaths(*db, *recommendationService, vm["track-path-from"].as(), vm["track-path-to"].as(), maxCount); + + if (vm.count("random-tracks")) + dumpRandomTracksRecommendation(*db, *recommendationService, vm["random-tracks"].as(), vm["seed"].as(), maxCount); + + if (vm.count("random-releases")) + dumpRandomReleasesRecommendation(*db, *recommendationService, vm["random-releases"].as(), vm["seed"].as(), maxCount); + + if (vm.count("random-artists")) + dumpRandomArtistsRecommendation(*db, *recommendationService, vm["random-artists"].as(), vm["seed"].as(), maxCount); } catch (std::exception& e) { diff --git a/src/tools/similarity-parameters/GeneticAlgorithm.hpp b/src/tools/similarity-parameters/GeneticAlgorithm.hpp deleted file mode 100644 index 9f2f534d..00000000 --- a/src/tools/similarity-parameters/GeneticAlgorithm.hpp +++ /dev/null @@ -1,167 +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 . - */ - -#include - -#include "core/Random.hpp" - -#include "ParallelFor.hpp" - -template -class GeneticAlgorithm -{ -public: - using Score = float; - - using BreedFunction = std::function; - using MutateFunction = std::function; - using ScoreFunction = std::function; - - struct Params - { - std::size_t nbWorkers{ 1 }; - std::size_t nbGenerations; - float crossoverRatio{ 0.5 }; - float mutationProbability{ 0.05 }; - BreedFunction breedFunction; - MutateFunction mutateFunction; - ScoreFunction scoreFunction; - }; - - GeneticAlgorithm(const Params& params); - - // Returns the individual that has the maximum score after processing the requested generations - Individual simulate(const std::vector& initialPopulation); - -private: - struct ScoredIndividual - { - Individual individual; - std::optional score{}; - }; - - void scoreAndSortPopulation(std::vector& population); - Score getTotalScore(const std::vector& population) const; - typename std::vector::const_iterator pickRandomRouletteWheel(const std::vector& population, Score totalScore); - - Params _params; -}; - -template -GeneticAlgorithm::GeneticAlgorithm(const Params& params) - : _params{ params } -{ -} - -template -Individual -GeneticAlgorithm::simulate(const std::vector& initialPopulation) -{ - const std::size_t childrenCountPerGeneration{ static_cast(initialPopulation.size() * _params.crossoverRatio) }; - if (initialPopulation.size() < 10) - throw std::runtime_error("Initial population must has at least 10 elements"); - - std::vector scoredPopulation; - scoredPopulation.reserve(initialPopulation.size()); - - std::transform(std::cbegin(initialPopulation), std::cend(initialPopulation), std::back_inserter(scoredPopulation), - [](const Individual& individual) { return ScoredIndividual{ individual }; }); - - scoreAndSortPopulation(scoredPopulation); - - for (std::size_t currentGeneration{}; currentGeneration < _params.nbGenerations; ++currentGeneration) - { - assert(scoredPopulation.size() == initialPopulation.size()); - std::cout << "Processing generation " << currentGeneration << "..." << std::endl; - std::cout << "Need to create " << childrenCountPerGeneration << " new children" << std::endl; - - // breed - const Score populationTotalScore{ getTotalScore(scoredPopulation) }; - std::vector children; - children.reserve(childrenCountPerGeneration); - - while (children.size() < childrenCountPerGeneration) - { - // Select two random parents using their score as weight - const auto itParent1{ pickRandomRouletteWheel(scoredPopulation, populationTotalScore) }; - const auto itParent2{ pickRandomRouletteWheel(scoredPopulation, populationTotalScore) }; - - if (itParent1 == itParent2) - continue; - - ScoredIndividual child{ _params.breedFunction(itParent1->individual, itParent2->individual) }; - - if (core::random::getRealRandom(float{}, float{ 1 }) <= _params.mutationProbability) - _params.mutateFunction(child.individual); - - children.emplace_back(std::move(child)); - } - - // Elitist selection - scoredPopulation.resize(initialPopulation.size() - childrenCountPerGeneration); - - scoredPopulation.insert(std::end(scoredPopulation), std::make_move_iterator(std::begin(children)), std::make_move_iterator(std::end(children))); - assert(scoredPopulation.size() == initialPopulation.size()); - - scoreAndSortPopulation(scoredPopulation); - - std::cout << "Mean score = " << getTotalScore(scoredPopulation) / scoredPopulation.size() << std::endl; - std::cout << "Current best score = " << *scoredPopulation.front().score << std::endl; - } - - std::cout << "Best score = " << *scoredPopulation.front().score << std::endl; - return scoredPopulation.front().individual; -} - -template -void GeneticAlgorithm::scoreAndSortPopulation(std::vector& scoredPopulation) -{ - parallel_foreach(_params.nbWorkers, std::begin(scoredPopulation), std::end(scoredPopulation), - [&](ScoredIndividual& scoredIndividual) { - if (!scoredIndividual.score) - scoredIndividual.score = _params.scoreFunction(scoredIndividual.individual); - }); - - std::sort(std::begin(scoredPopulation), std::end(scoredPopulation), [](const ScoredIndividual& a, const ScoredIndividual& b) { return a.score > b.score; }); -} - -template -typename GeneticAlgorithm::Score -GeneticAlgorithm::getTotalScore(const std::vector& scoredPopulation) const -{ - return std::accumulate(std::cbegin(scoredPopulation), std::cend(scoredPopulation), Score{}, [](Score score, const ScoredIndividual& individual) { return score + *individual.score; }); -} - -template -typename std::vector::ScoredIndividual>::const_iterator -GeneticAlgorithm::pickRandomRouletteWheel(const std::vector& population, Score totalScore) -{ - const Score randomScore{ core::random::getRealRandom(Score{}, totalScore) }; - - Score curScore{}; - for (auto itScoredIndividual{ std::cbegin(population) }; itScoredIndividual != std::cend(population); ++itScoredIndividual) - { - if (curScore + *itScoredIndividual->score > randomScore) - return itScoredIndividual; - - curScore += *itScoredIndividual->score; - } - - throw std::runtime_error("bad random or empty population"); -} diff --git a/src/tools/similarity-parameters/LmsSimilarityParameters.cpp b/src/tools/similarity-parameters/LmsSimilarityParameters.cpp deleted file mode 100644 index 0955357b..00000000 --- a/src/tools/similarity-parameters/LmsSimilarityParameters.cpp +++ /dev/null @@ -1,470 +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 . - */ - -#include -#include -#include - -#include "core/Config.hpp" -#include "core/Service.hpp" -#include "core/StreamLogger.hpp" -#include "database/IDb.hpp" -#include "database/SessionPool.hpp" -#include "database/objects/Artist.hpp" -#include "database/objects/Cluster.hpp" -#include "database/objects/Release.hpp" -#include "database/objects/Track.hpp" -#include "database/objects/TrackFeatures.hpp" -#include "similarity/features/SimilarityFeaturesSearcher.hpp" - -#include "GeneticAlgorithm.hpp" - -using namespace Similarity; -using SimilarityScore = GeneticAlgorithm::Score; - -// An individual is just a FeatureSettingsMap -// The goal is to get the FeatureSettingsMap that maximize the score -const FeatureSettingsMap featuresSettings{ - { "lowlevel.average_loudness", { 1 } }, - { "lowlevel.barkbands.mean", { 1 } }, - { "lowlevel.barkbands.median", { 1 } }, - { "lowlevel.barkbands.var", { 1 } }, - { "lowlevel.barkbands_crest.mean", { 1 } }, - { "lowlevel.barkbands_crest.median", { 1 } }, - { "lowlevel.barkbands_crest.var", { 1 } }, - { "lowlevel.barkbands_flatness_db.mean", { 1 } }, - { "lowlevel.barkbands_flatness_db.median", { 1 } }, - { "lowlevel.barkbands_flatness_db.var", { 1 } }, - { "lowlevel.barkbands_kurtosis.mean", { 1 } }, - { "lowlevel.barkbands_kurtosis.median", { 1 } }, - { "lowlevel.barkbands_kurtosis.var", { 1 } }, - { "lowlevel.barkbands_skewness.mean", { 1 } }, - { "lowlevel.barkbands_skewness.median", { 1 } }, - { "lowlevel.barkbands_skewness.var", { 1 } }, - { "lowlevel.barkbands_spread.mean", { 1 } }, - { "lowlevel.barkbands_spread.median", { 1 } }, - { "lowlevel.barkbands_spread.var", { 1 } }, - { "lowlevel.dissonance.mean", { 1 } }, - { "lowlevel.dissonance.median", { 1 } }, - { "lowlevel.dissonance.var", { 1 } }, - { "lowlevel.dynamic_complexity", { 1 } }, - { "lowlevel.spectral_contrast_coeffs.mean", { 1 } }, - { "lowlevel.spectral_contrast_coeffs.median", { 1 } }, - { "lowlevel.spectral_contrast_coeffs.var", { 1 } }, - { "lowlevel.erbbands.mean", { 1 } }, - { "lowlevel.erbbands.median", { 1 } }, - { "lowlevel.erbbands.var", { 1 } }, - { "lowlevel.gfcc.mean", { 1 } }, - { "lowlevel.hfc.mean", { 1 } }, - { "lowlevel.hfc.median", { 1 } }, - { "lowlevel.hfc.var", { 1 } }, - { "tonal.hpcp.median", { 1 } }, - { "lowlevel.melbands.mean", { 1 } }, - { "lowlevel.melbands.median", { 1 } }, - { "lowlevel.melbands.var", { 1 } }, - { "lowlevel.melbands_crest.mean", { 1 } }, - { "lowlevel.melbands_crest.median", { 1 } }, - { "lowlevel.melbands_crest.var", { 1 } }, - { "lowlevel.melbands_flatness_db.mean", { 1 } }, - { "lowlevel.melbands_flatness_db.median", { 1 } }, - { "lowlevel.melbands_flatness_db.var", { 1 } }, - { "lowlevel.melbands_kurtosis.mean", { 1 } }, - { "lowlevel.melbands_kurtosis.median", { 1 } }, - { "lowlevel.melbands_kurtosis.var", { 1 } }, - { "lowlevel.melbands_skewness.mean", { 1 } }, - { "lowlevel.melbands_skewness.median", { 1 } }, - { "lowlevel.melbands_skewness.var", { 1 } }, - { "lowlevel.melbands_spread.mean", { 1 } }, - { "lowlevel.melbands_spread.median", { 1 } }, - { "lowlevel.melbands_spread.var", { 1 } }, - { "lowlevel.mfcc.mean", { 1 } }, - { "lowlevel.pitch_salience.mean", { 1 } }, - { "lowlevel.pitch_salience.median", { 1 } }, - { "lowlevel.pitch_salience.var", { 1 } }, - { "lowlevel.silence_rate_30dB.mean", { 1 } }, - { "lowlevel.silence_rate_30dB.median", { 1 } }, - { "lowlevel.silence_rate_30dB.var", { 1 } }, - { "lowlevel.silence_rate_60dB.mean", { 1 } }, - { "lowlevel.silence_rate_60dB.median", { 1 } }, - { "lowlevel.silence_rate_60dB.var", { 1 } }, - { "lowlevel.spectral_centroid.mean", { 1 } }, - { "lowlevel.spectral_centroid.median", { 1 } }, - { "lowlevel.spectral_centroid.var", { 1 } }, - { "lowlevel.spectral_complexity.mean", { 1 } }, - { "lowlevel.spectral_complexity.median", { 1 } }, - { "lowlevel.spectral_complexity.var", { 1 } }, - { "lowlevel.spectral_contrast_coeffs.mean", { 1 } }, - { "lowlevel.spectral_contrast_coeffs.median", { 1 } }, - { "lowlevel.spectral_contrast_coeffs.var", { 1 } }, - { "lowlevel.spectral_contrast_valleys.mean", { 1 } }, - { "lowlevel.spectral_contrast_valleys.median", { 1 } }, - { "lowlevel.spectral_contrast_valleys.var", { 1 } }, - { "lowlevel.spectral_decrease.mean", { 1 } }, - { "lowlevel.spectral_decrease.median", { 1 } }, - { "lowlevel.spectral_decrease.var", { 1 } }, - { "lowlevel.spectral_energy.mean", { 1 } }, - { "lowlevel.spectral_energy.median", { 1 } }, - { "lowlevel.spectral_energy.var", { 1 } }, - { "lowlevel.spectral_energyband_high.mean", { 1 } }, - { "lowlevel.spectral_energyband_high.median", { 1 } }, - { "lowlevel.spectral_energyband_high.var", { 1 } }, - { "lowlevel.spectral_energyband_low.mean", { 1 } }, - { "lowlevel.spectral_energyband_low.median", { 1 } }, - { "lowlevel.spectral_energyband_low.var", { 1 } }, - { "lowlevel.spectral_energyband_middle_high.mean", { 1 } }, - { "lowlevel.spectral_energyband_middle_high.median", { 1 } }, - { "lowlevel.spectral_energyband_middle_high.var", { 1 } }, - { "lowlevel.spectral_energyband_middle_low.mean", { 1 } }, - { "lowlevel.spectral_energyband_middle_low.median", { 1 } }, - { "lowlevel.spectral_energyband_middle_low.var", { 1 } }, - { "lowlevel.spectral_entropy.mean", { 1 } }, - { "lowlevel.spectral_entropy.median", { 1 } }, - { "lowlevel.spectral_entropy.var", { 1 } }, - { "lowlevel.spectral_flux.mean", { 1 } }, - { "lowlevel.spectral_flux.median", { 1 } }, - { "lowlevel.spectral_flux.var", { 1 } }, - { "lowlevel.spectral_kurtosis.mean", { 1 } }, - { "lowlevel.spectral_kurtosis.median", { 1 } }, - { "lowlevel.spectral_kurtosis.var", { 1 } }, - { "lowlevel.spectral_rms.mean", { 1 } }, - { "lowlevel.spectral_rms.median", { 1 } }, - { "lowlevel.spectral_rms.var", { 1 } }, - { "lowlevel.spectral_rolloff.mean", { 1 } }, - { "lowlevel.spectral_rolloff.median", { 1 } }, - { "lowlevel.spectral_rolloff.var", { 1 } }, - { "lowlevel.spectral_skewness.mean", { 1 } }, - { "lowlevel.spectral_skewness.median", { 1 } }, - { "lowlevel.spectral_skewness.var", { 1 } }, - { "lowlevel.spectral_spread.mean", { 1 } }, - { "lowlevel.spectral_spread.median", { 1 } }, - { "lowlevel.spectral_spread.var", { 1 } }, - { "lowlevel.zerocrossingrate.mean", { 1 } }, - { "lowlevel.zerocrossingrate.median", { 1 } }, - { "lowlevel.zerocrossingrate.var", { 1 } }, -}; - -static std::unordered_map -constructFeaturesCache(db::Session& session, const FeatureSettingsMap& featureSettings) -{ - std::unordered_map cache; - - std::unordered_set names; - std::transform(std::cbegin(featureSettings), std::cend(featureSettings), std::inserter(names, std::begin(names)), - [](const auto& itFeature) { return itFeature.first; }); - - auto transaction{ session.createReadTransaction() }; - - for (auto trackId : db::Track::getAllIdsWithFeatures(session)) - { - const db::Track::pointer track{ db::Track::getById(session, trackId) }; - const db::TrackFeatures::pointer trackFeatures{ track->getTrackFeatures() }; - - cache[trackId] = trackFeatures->getFeatureValuesMap(names); - } - - return cache; -} - -static std::optional -getFeaturesFromCache(const std::unordered_map& cache, db::IdType trackId, const FeatureNames& names) -{ - std::optional res; - - auto it{ cache.find(trackId) }; - if (it == std::cend(cache)) - return res; - - res = FeatureValuesMap{}; - - const FeatureValuesMap& trackFeatures{ it->second }; - for (const FeatureName& name : names) - { - auto itFeatures{ trackFeatures.find(name) }; - if (itFeatures == std::cend(trackFeatures)) - { - res.reset(); - break; - } - - res->emplace(name, itFeatures->second); - } - - return res; -} - -static void -printFeatureSettingsMap(const FeatureSettingsMap& featureSettings) -{ - std::cout << "FeatureSettingsMap: (" << featureSettings.size() << " features)" << std::endl; - for (const auto& [name, settings] : featureSettings) - std::cout << "\t" << name << std::endl; -} - -static std::string -trackToString(db::Session& session, db::IdType trackId) -{ - std::string res; - auto transaction{ session.createReadTransaction() }; - db::Track::pointer track{ db::Track::getById(session, trackId) }; - - res += track->getName(); - if (track->getRelease()) - res += " [" + track->getRelease()->getName() + "]"; - for (auto artist : track->getArtists()) - res += " - " + artist->getName(); - for (auto cluster : track->getClusters()) - res += " {" + cluster->getType()->getName() + "-" + cluster->getName() + "}"; - - return res; -} - -static SimilarityScore -computeTrackScore(db::Session& session, db::IdType track1Id, db::IdType track2Id) -{ - SimilarityScore score{}; - - auto transaction{ session.createReadTransaction() }; - - auto track1{ db::Track::getById(session, track1Id) }; - auto track2{ db::Track::getById(session, track2Id) }; - - if (track1->getRelease() == track2->getRelease()) - score += 1; - - // Artists in common - { - auto track1ArtistIds{ track1->getArtistIds() }; - auto track2ArtistIds{ track2->getArtistIds() }; - - std::vector commonArtistIds; - std::set_intersection(std::cbegin(track1ArtistIds), std::cend(track1ArtistIds), - std::cbegin(track2ArtistIds), std::cend(track2ArtistIds), - std::back_inserter(commonArtistIds)); - - score += commonArtistIds.size(); - } - - // Clusters in common - { - auto track1ClusterIds{ track1->getClusterIds() }; - auto track2ClusterIds{ track2->getClusterIds() }; - - std::vector commonClusterIds; - std::set_intersection(std::cbegin(track1ClusterIds), std::cend(track1ClusterIds), - std::cbegin(track2ClusterIds), std::cend(track2ClusterIds), - std::back_inserter(commonClusterIds)); - - score += commonClusterIds.size(); - } - - return score; -} - -static SimilarityScore -computeSimilarityScore(db::Session& session, FeaturesSearcher::TrainSettings trainSettings) -{ - std::cout << "Compute score of: "; - printFeatureSettingsMap(trainSettings.featureSettingsMap); - std::cout << std::endl; - - FeaturesSearcher searcher{ session, trainSettings }; - - const std::vector trackIds = std::invoke([&]() { - auto transaction{ session.createReadTransaction() }; - return db::Track::getAllIdsWithFeatures(session); - }); - - SimilarityScore score{}; - for (db::IdType trackId : trackIds) - { - constexpr std::size_t nbSimilarTracks{ 3 }; - // std::cout << "Processing track '" << trackToString(session, trackId) << "'" << std::endl; - SimilarityScore factor{ 1 }; - for (db::IdType similarTrackId : searcher.getSimilarTracks({ trackId }, nbSimilarTracks)) - { - SimilarityScore trackScore{ computeTrackScore(session, trackId, similarTrackId) }; - // std::cout << "\tScore = " << trackScore << " (*" << factor << ") with track '" << trackToString(session, similarTrackId) << "'" << std::endl; - trackScore *= factor; - score += trackScore; - - factor -= (SimilarityScore{ 1 } / nbSimilarTracks); - } - } - - std::cout << "Total score = " << score << std::endl; - - return score; -} - -static void -printBadlyClassifiedTracks(db::Session& session, FeaturesSearcher::TrainSettings trainSettings) -{ - FeaturesSearcher searcher{ session, trainSettings }; - - const std::vector trackIds = std::invoke([&]() { - auto transaction{ session.createReadTransaction() }; - return db::Track::getAllIdsWithFeatures(session); - }); - - for (db::IdType trackId : trackIds) - { - constexpr std::size_t nbSimilarTracks{ 3 }; - for (db::IdType similarTrackId : searcher.getSimilarTracks({ trackId }, nbSimilarTracks)) - { - SimilarityScore trackScore{ computeTrackScore(session, trackId, similarTrackId) }; - if (trackScore == 0) - std::cout << "Badly classified tracks: '" << trackToString(session, trackId) << "'\n\twith track '" << trackToString(session, similarTrackId) << "'" << std::endl; - } - } -} - -static FeatureSettingsMap -breedFeatureSettingsMap(const FeatureSettingsMap& a, const FeatureSettingsMap& b) -{ - FeatureSettingsMap res; - - res.insert(std::cbegin(a), std::cend(a)); - res.insert(std::cbegin(b), std::cend(b)); - - // just kill random elements until size is good - while (res.size() > a.size()) - { - const auto itFeature{ core::random::pickRandom(res) }; - res.erase(itFeature); - } - - return res; -} - -static void -mutateFeatureSettingsMap(FeatureSettingsMap& a) -{ - const std::size_t size{ a.size() }; - // Replace one of the feature with another one, random - a.erase(core::random::pickRandom(a)); - - while (a.size() != size) - { - const auto itFeatureSetting{ core::random::pickRandom(featuresSettings) }; - a.emplace(itFeatureSetting->first, itFeatureSetting->second); - } -} - -int main(int argc, char* argv[]) -{ - try - { - // log to stdout - // ServiceProvider::create(std::cout); - - if (argc != 3) - { - std::cerr << "usage: " << std::endl; - return EXIT_FAILURE; - } - - const std::filesystem::path configFilePath{ std::string(argv[1], 0, 256) }; - const std::size_t nbWorkers = atoi(argv[2]); - - ServiceProvider::create(configFilePath); - - db::Db db{ ServiceProvider::get()->getPath("working-dir", "/var/lms") / "lms.db" }; - db::SessionPool sessionPool{ db, nbWorkers }; - - std::cout << "Caching all features..." << std::endl; - // Cache all the features of all the music in order to speed up the multiple trainings - const auto cachedFeatures{ constructFeaturesCache(db::SessionPool::ScopedSession{ sessionPool }.get(), featuresSettings) }; - std::cout << "Caching all features DONE" << std::endl; - - FeaturesSearcher::setFeaturesFetchFunc( - [&](db::IdType trackId, const FeatureNames& featureNames) { - return getFeaturesFromCache(cachedFeatures, trackId, featureNames); - }); - - // Create some random settings (i.e random population) - std::vector initialPopulation; - - constexpr std::size_t populationSize{ 200 }; - constexpr std::size_t nbFeatures{ 5 }; - - for (std::size_t i{}; i < populationSize; ++i) - { - FeatureSettingsMap settings; - - while (settings.size() < nbFeatures) - { - const auto itFeatureSetting{ core::random::pickRandom(featuresSettings) }; - settings.emplace(itFeatureSetting->first, itFeatureSetting->second); - } - - initialPopulation.emplace_back(std::move(settings)); - } - - FeaturesSearcher::TrainSettings trainSettings; - trainSettings.iterationCount = 8; - trainSettings.sampleCountPerNeuron = 1.5; - - GeneticAlgorithm::Params params; - params.nbWorkers = nbWorkers; - params.nbGenerations = 1; - params.crossoverRatio = 0.78; - params.mutationProbability = 0.2; - params.breedFunction = breedFeatureSettingsMap; - params.mutateFunction = mutateFeatureSettingsMap; - params.scoreFunction = - [&](const FeatureSettingsMap& featureSettings) { - FeaturesSearcher::TrainSettings settings{ trainSettings }; - settings.featureSettingsMap = featureSettings; - - db::SessionPool::ScopedSession scopedSession{ sessionPool }; - return computeSimilarityScore(scopedSession.get(), settings); - }; - - GeneticAlgorithm geneticAlgorithm{ params }; - - std::cout << "Parameters:\n" - << "\tnb total settings = " << featuresSettings.size() << "\n" - << "\tnb generations = " << params.nbGenerations << "\n" - << "\tpopulationSize = " << populationSize << "\n" - << "\tnbFeatures = " << nbFeatures << "\n" - << "\tcrossoverRatio = " << params.crossoverRatio << "\n" - << "\tmutationProbability = " << params.mutationProbability << "\n" - << std::endl; - - std::cout << "Starting simulation..." << std::endl; - const FeatureSettingsMap selectedSettings{ geneticAlgorithm.simulate(initialPopulation) }; - std::cout << "Simulation complete! Best result:" << std::endl; - printFeatureSettingsMap(selectedSettings); - - // print all badly classified tracks - { - FeaturesSearcher::TrainSettings settings{ trainSettings }; - settings.featureSettingsMap = selectedSettings; - - db::SessionPool::ScopedSession scopedSession{ sessionPool }; - printBadlyClassifiedTracks(scopedSession.get(), settings); - } - } - catch (std::exception& e) - { - std::cerr << "Caught exception: " << e.what() << std::endl; - } - - return EXIT_SUCCESS; -} diff --git a/src/tools/similarity-parameters/ParallelFor.hpp b/src/tools/similarity-parameters/ParallelFor.hpp deleted file mode 100644 index 4ddea4e4..00000000 --- a/src/tools/similarity-parameters/ParallelFor.hpp +++ /dev/null @@ -1,47 +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 . - */ - -#include -#include - -#include - -template -void parallel_foreach(std::size_t nbWorkers, It begin, It end, Func&& func) -{ - if (nbWorkers == 0) - throw std::runtime_error("Invalid worker count"); - - boost::asio::io_context ioContext; - - for (It it{ begin }; it != end; ++it) - { - auto refValue{ std::ref(*it) }; - ioContext.post([refValue, &func]() { std::cout << "EXEC FROM WORKER" << std::endl; func(refValue); std::cout << "END EXEC FROM WORKER" << std::endl; }); - } - - std::vector threads; - for (std::size_t i{}; i < nbWorkers - 1; ++i) - threads.emplace_back([&]() { ioContext.run(); }); - - ioContext.run(); - - for (std::thread& t : threads) - t.join(); -}