From 2a69f6dc33b031944236b6e62e2a3c13346e3ca7 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 08:31:54 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv --- internal/subsonic/browsing_extra.go | 68 +++++++++++++- internal/subsonic/similar_songs_sonic_test.go | 91 +++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 internal/subsonic/similar_songs_sonic_test.go diff --git a/internal/subsonic/browsing_extra.go b/internal/subsonic/browsing_extra.go index d7a031a..7e2f866 100644 --- a/internal/subsonic/browsing_extra.go +++ b/internal/subsonic/browsing_extra.go @@ -4,10 +4,12 @@ import ( "database/sql" "os" "path/filepath" + "sort" "strconv" "strings" "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/similarity" "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 -// "id" can be any of the three in real Subsonic usage) and finds -// genre-similar songs for it. +// "id" can be any of the three in real Subsonic usage), finds +// 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) { 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 { songs, err := database.ListSongsByAlbum(db, id) if err != nil || len(songs) == 0 { 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 { 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 } +// 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 { count := rc.Params.Int("count", 50) db := rc.Server.DB diff --git a/internal/subsonic/similar_songs_sonic_test.go b/internal/subsonic/similar_songs_sonic_test.go new file mode 100644 index 0000000..3c7b643 --- /dev/null +++ b/internal/subsonic/similar_songs_sonic_test.go @@ -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) + } +}