Blend real sonic distance into classic getSimilarSongs ranking
similarSongsFor still filters by genre (unchanged, and getSonicSimilarTracks remains the pure-sonic endpoint), but when the seed song has been analyzed, genre-matched candidates that also have stored audio_features are now ordered by actual feature-space distance from the seed instead of SQL's random order; candidates without analysis data are left at the end in their existing relative order rather than dropped or given a fabricated distance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
@@ -4,10 +4,12 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/similarity"
|
||||||
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,18 +184,32 @@ func handleGetSimilarSongs2(rc *Context) *apiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's
|
// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's
|
||||||
// "id" can be any of the three in real Subsonic usage) and finds
|
// "id" can be any of the three in real Subsonic usage), finds
|
||||||
// genre-similar songs for it.
|
// genre-similar songs for it, and — when the seed song has been sonic-
|
||||||
|
// analyzed — re-ranks that genre-matched candidate set by actual sonic
|
||||||
|
// distance rather than leaving it in the database's random order. This
|
||||||
|
// never replaces the genre filter with pure sonic search (that's what
|
||||||
|
// getSonicSimilarTracks is for); it just makes "similar" among
|
||||||
|
// genre-mates mean something more concrete when the data exists to
|
||||||
|
// back it up.
|
||||||
func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) {
|
func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) {
|
||||||
if song, err := database.GetSong(db, id); err == nil {
|
if song, err := database.GetSong(db, id); err == nil {
|
||||||
return database.SimilarSongsByGenre(db, song.ID, count)
|
songs, err := database.SimilarSongsByGenre(db, song.ID, count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reorderBySonicSimilarity(db, song.ID, songs), nil
|
||||||
}
|
}
|
||||||
if _, err := database.GetAlbum(db, id); err == nil {
|
if _, err := database.GetAlbum(db, id); err == nil {
|
||||||
songs, err := database.ListSongsByAlbum(db, id)
|
songs, err := database.ListSongsByAlbum(db, id)
|
||||||
if err != nil || len(songs) == 0 {
|
if err != nil || len(songs) == 0 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return database.SimilarSongsByGenre(db, songs[0].ID, count)
|
similar, err := database.SimilarSongsByGenre(db, songs[0].ID, count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reorderBySonicSimilarity(db, songs[0].ID, similar), nil
|
||||||
}
|
}
|
||||||
if _, err := database.GetArtist(db, id); err == nil {
|
if _, err := database.GetArtist(db, id); err == nil {
|
||||||
return database.SimilarSongsByArtist(db, id, count)
|
return database.SimilarSongsByArtist(db, id, count)
|
||||||
@@ -201,6 +217,50 @@ func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error)
|
|||||||
return nil, database.ErrNotFound
|
return nil, database.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reorderBySonicSimilarity sorts songs by real sonic distance from
|
||||||
|
// seedSongID when both the seed and (some of) the candidates have
|
||||||
|
// stored audio_features; candidates without analysis data are left in
|
||||||
|
// their existing relative order at the end, never dropped and never
|
||||||
|
// assigned a fabricated distance.
|
||||||
|
func reorderBySonicSimilarity(db *sql.DB, seedSongID string, songs []*database.Song) []*database.Song {
|
||||||
|
if len(songs) < 2 {
|
||||||
|
return songs
|
||||||
|
}
|
||||||
|
seed, err := database.GetAudioFeatures(db, seedSongID)
|
||||||
|
if err != nil || seed == nil {
|
||||||
|
return songs
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []similarity.Item{{ID: seedSongID, Vector: seed.Vector()}}
|
||||||
|
analyzed := map[string]bool{}
|
||||||
|
for _, s := range songs {
|
||||||
|
f, err := database.GetAudioFeatures(db, s.ID)
|
||||||
|
if err != nil || f == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, similarity.Item{ID: s.ID, Vector: f.Vector()})
|
||||||
|
analyzed[s.ID] = true
|
||||||
|
}
|
||||||
|
if len(items) < 2 {
|
||||||
|
return songs
|
||||||
|
}
|
||||||
|
idx := similarity.Build(items)
|
||||||
|
|
||||||
|
sorted := make([]*database.Song, len(songs))
|
||||||
|
copy(sorted, songs)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
ai, aj := analyzed[sorted[i].ID], analyzed[sorted[j].ID]
|
||||||
|
if ai != aj {
|
||||||
|
return ai // analyzed songs sort before unanalyzed ones
|
||||||
|
}
|
||||||
|
if !ai {
|
||||||
|
return false // preserve relative order among unanalyzed songs
|
||||||
|
}
|
||||||
|
return idx.DistanceBetween(seedSongID, sorted[i].ID) < idx.DistanceBetween(seedSongID, sorted[j].ID)
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
func handleGetTopSongs(rc *Context) *apiError {
|
func handleGetTopSongs(rc *Context) *apiError {
|
||||||
count := rc.Params.Int("count", 50)
|
count := rc.Params.Int("count", 50)
|
||||||
db := rc.Server.DB
|
db := rc.Server.DB
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||||
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeGenreSong(t *testing.T, s *Server, folderID, genreID, title string) *database.Song {
|
||||||
|
t.Helper()
|
||||||
|
mf, err := database.CreateMediaFile(s.DB, folderID, title+".mp3", 100, 0, "hash-"+title, "mp3", "song", 10, 128, 44100, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: title})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.LinkSongGenre(s.DB, song.ID, genreID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return song
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarSongsForReordersGenreCandidatesBySonicDistance(t *testing.T) {
|
||||||
|
s, db := newTestServer(t)
|
||||||
|
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
genre, err := database.FindOrCreateGenre(db, "Rock")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seed := makeGenreSong(t, s, folder.ID, genre.ID, "Seed")
|
||||||
|
if err := database.UpsertAudioFeatures(db, seed.ID, &audiofeatures.Features{TempoBPM: 100, TempoValid: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
near := makeGenreSong(t, s, folder.ID, genre.ID, "Near")
|
||||||
|
if err := database.UpsertAudioFeatures(db, near.ID, &audiofeatures.Features{TempoBPM: 105, TempoValid: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
far := makeGenreSong(t, s, folder.ID, genre.ID, "Far")
|
||||||
|
if err := database.UpsertAudioFeatures(db, far.ID, &audiofeatures.Features{TempoBPM: 200, TempoValid: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = makeGenreSong(t, s, folder.ID, genre.ID, "Unanalyzed")
|
||||||
|
|
||||||
|
songs, err := similarSongsFor(db, seed.ID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("similarSongsFor: %v", err)
|
||||||
|
}
|
||||||
|
if len(songs) != 3 {
|
||||||
|
t.Fatalf("songs = %v, want 3 (near, far, unanalyzed)", songs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pos := map[string]int{}
|
||||||
|
for i, sg := range songs {
|
||||||
|
pos[sg.Title] = i
|
||||||
|
}
|
||||||
|
if pos["Near"] >= pos["Far"] {
|
||||||
|
t.Errorf("expected Near (105bpm) to rank before Far (200bpm), got order %v", songs)
|
||||||
|
}
|
||||||
|
if pos["Unanalyzed"] != len(songs)-1 {
|
||||||
|
t.Errorf("expected the unanalyzed candidate last, got order %v", songs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarSongsForNoAudioFeaturesLeavesOrderAlone(t *testing.T) {
|
||||||
|
s, db := newTestServer(t)
|
||||||
|
folder, err := database.CreateMusicFolder(db, "Music", "/music", "music")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
genre, err := database.FindOrCreateGenre(db, "Rock")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seed := makeGenreSong(t, s, folder.ID, genre.ID, "Seed")
|
||||||
|
makeGenreSong(t, s, folder.ID, genre.ID, "Other")
|
||||||
|
|
||||||
|
songs, err := similarSongsFor(db, seed.ID, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("similarSongsFor: %v", err)
|
||||||
|
}
|
||||||
|
if len(songs) != 1 || songs[0].Title != "Other" {
|
||||||
|
t.Fatalf("songs = %v, want [Other] (no analysis on either side, no reorder crash)", songs)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user