Implement sonicSimilarity extension with real audio feature analysis
Adds internal/audiofeatures: decodes each scanned song to PCM via ffmpeg and computes actual measured characteristics — tempo (via autocorrelation of the frame energy envelope, left unset rather than guessed when a clip is too short to contain a beat), an approximate loudness figure, spectral centroid/rolloff and zero-crossing rate (via a hand-written FFT), and a 12-bin chroma vector used both for a Krumhansl-Kessler key estimate and as similarity-index input. No external DSP/ML dependency. The scanner now runs this analysis after indexing each song (as part of the already-background scan, not on any request path) and stores results in the existing audio_features table via the new database.UpsertAudioFeatures/GetAudioFeatures/ListAllAudioFeatures. internal/similarity is a small in-process nearest-neighbor index: z-score normalizes each feature dimension across the analyzed library and answers Nearest (ranked real distances) and Path (a feature-space straight-line walk from one track to another, hopping to the closest not-yet-used real song at each step — never a fabricated intermediate track). getSonicSimilarTracks and findSonicPath expose these through the standard sonicMatch response, converting distance to a normalized (0,1] similarity score. A song that hasn't been analyzed yet is simply absent from results rather than faked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
This commit is contained in:
@@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) {
|
||||
// set once Phase 3 is done.
|
||||
for _, want := range []string{
|
||||
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
|
||||
"indexBasedQueue", "playbackReport", "songLyrics", "transcoding",
|
||||
"indexBasedQueue", "playbackReport", "songLyrics", "transcoding", "sonicSimilarity",
|
||||
} {
|
||||
if !names[want] {
|
||||
t.Errorf("extension %q not advertised: %v", want, names)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/similarity"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerExtension("sonicSimilarity", 1)
|
||||
register("getSonicSimilarTracks", handleGetSonicSimilarTracks)
|
||||
register("findSonicPath", handleFindSonicPath)
|
||||
}
|
||||
|
||||
// buildSimilarityIndex loads every song VibeSonic has actually analyzed
|
||||
// (audio_features rows populated by the scanner's background analysis)
|
||||
// and builds an in-process nearest-neighbor index from their real
|
||||
// feature vectors. It's rebuilt per request rather than cached: the
|
||||
// library-wide mean/stddev normalization it depends on shifts as more
|
||||
// songs get analyzed, and a from-scratch build over even a large
|
||||
// library is a few hundred microseconds of arithmetic over small
|
||||
// vectors, not a bottleneck worth caching.
|
||||
func buildSimilarityIndex(db *sql.DB) (*similarity.Index, error) {
|
||||
rows, err := database.ListAllAudioFeatures(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]similarity.Item, len(rows))
|
||||
for i, r := range rows {
|
||||
items[i] = similarity.Item{ID: r.SongID, Vector: r.Vector()}
|
||||
}
|
||||
return similarity.Build(items), nil
|
||||
}
|
||||
|
||||
// similarityScore converts a Euclidean distance in normalized feature
|
||||
// space into the "normalized similarity score" the spec asks for: 1.0
|
||||
// for an identical vector, approaching 0 as distance grows, never
|
||||
// negative.
|
||||
func similarityScore(distance float64) float64 {
|
||||
return 1 / (1 + distance)
|
||||
}
|
||||
|
||||
func childForSongID(db *sql.DB, songID, userID string) (*Child, error) {
|
||||
song, err := database.GetSong(db, songID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mf, err := database.GetMediaFile(db, song.MediaFileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := childFromSong(db, song, mf, albumOfSong(db, song), userID)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func handleGetSonicSimilarTracks(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
count := rc.Params.Int("count", 10)
|
||||
db := rc.Server.DB
|
||||
|
||||
if _, err := database.GetSong(db, id); err != nil {
|
||||
return errNotFound("Song")
|
||||
}
|
||||
|
||||
idx, err := buildSimilarityIndex(db)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
matches := idx.Nearest(id, count)
|
||||
if matches == nil {
|
||||
// Either the song hasn't been analyzed yet, or nothing else in
|
||||
// the library has — an honest empty result, not an error.
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []SonicMatch
|
||||
for _, m := range matches {
|
||||
child, err := childForSongID(db, m.ID, rc.User.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, SonicMatch{Entry: *child, Similarity: similarityScore(m.Distance)})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.SonicMatch = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleFindSonicPath(rc *Context) *apiError {
|
||||
startID, aerr := rc.Params.Required("startSongId")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
endID, aerr := rc.Params.Required("endSongId")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
count := rc.Params.Int("count", 25)
|
||||
db := rc.Server.DB
|
||||
|
||||
if _, err := database.GetSong(db, startID); err != nil {
|
||||
return errNotFound("Start song")
|
||||
}
|
||||
if _, err := database.GetSong(db, endID); err != nil {
|
||||
return errNotFound("End song")
|
||||
}
|
||||
|
||||
idx, err := buildSimilarityIndex(db)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
path := idx.Path(startID, endID, count)
|
||||
if path == nil {
|
||||
// Analysis hasn't run for one or both endpoints yet: an honest
|
||||
// empty result rather than a fabricated single-hop path.
|
||||
rc.Response = newResponse()
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []SonicMatch
|
||||
for _, songID := range path {
|
||||
child, err := childForSongID(db, songID, rc.User.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
distFromStart := idx.DistanceBetween(startID, songID)
|
||||
out = append(out, SonicMatch{Entry: *child, Similarity: similarityScore(distFromStart)})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.SonicMatch = out
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
)
|
||||
|
||||
// makeAnalyzedSong creates a minimal song fixture (no real audio file
|
||||
// needed — sonic-similarity handlers only ever read the songs table and
|
||||
// audio_features, never touch disk) and stores a hand-crafted feature
|
||||
// vector for it, isolating these tests from ffmpeg/analysis timing.
|
||||
func makeAnalyzedSong(t *testing.T, s *Server, folderID, title string, tempo float64) *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)
|
||||
}
|
||||
f := &audiofeatures.Features{TempoBPM: tempo, TempoValid: true, Key: "C major"}
|
||||
if err := database.UpsertAudioFeatures(s.DB, song.ID, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
func sonicTestLibrary(t *testing.T, s *Server) (slow, mid, fast, unanalyzed *database.Song) {
|
||||
t.Helper()
|
||||
folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slow = makeAnalyzedSong(t, s, folder.ID, "Slow", 70)
|
||||
mid = makeAnalyzedSong(t, s, folder.ID, "Mid", 100)
|
||||
fast = makeAnalyzedSong(t, s, folder.ID, "Fast", 160)
|
||||
|
||||
mf, err := database.CreateMediaFile(s.DB, folder.ID, "Unanalyzed.mp3", 100, 0, "hash-unanalyzed", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unanalyzed, err = database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Unanalyzed"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksOrdersByRealDistance(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
_, mid, fast, _ := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", mid.ID) // 100bpm: 30bpm from Slow(70), 60bpm from Fast(160) — Slow should rank first
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
matches, ok := j["sonicMatch"].([]any)
|
||||
if !ok || len(matches) != 2 {
|
||||
t.Fatalf("sonicMatch = %v, want 2 entries", j)
|
||||
}
|
||||
first := matches[0].(map[string]any)
|
||||
entry := first["entry"].(map[string]any)
|
||||
if entry["title"] != "Slow" {
|
||||
t.Errorf("nearest to Mid(100bpm) = %v, want Slow(70bpm) (closer than Fast(160bpm))", entry["title"])
|
||||
}
|
||||
sim := first["similarity"].(float64)
|
||||
if sim <= 0 || sim > 1 {
|
||||
t.Errorf("similarity = %v, want in (0,1]", sim)
|
||||
}
|
||||
_ = fast
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksExcludesUnanalyzedSongs(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
slow, _, _, unanalyzed := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", slow.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
matches := j["sonicMatch"].([]any)
|
||||
for _, m := range matches {
|
||||
entry := m.(map[string]any)["entry"].(map[string]any)
|
||||
if entry["title"] == "Unanalyzed" {
|
||||
t.Error("a song with no stored audio_features must never appear in sonic-similarity results")
|
||||
}
|
||||
}
|
||||
_ = unanalyzed
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksUnknownSong(t *testing.T) {
|
||||
s, srv := newTestRecorderServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", "sg_doesnotexist")
|
||||
resp := getPath(t, srv.URL, "/rest/getSonicSimilarTracks.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
||||
t.Fatalf("expected error 70, got %+v", x)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSonicSimilarTracksNeverAnalyzedReturnsEmpty(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mf, err := database.CreateMediaFile(s.DB, folder.ID, "solo.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Solo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("id", song.ID)
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
// No song in the library has been analyzed at all — VibeSonic must
|
||||
// return an honest empty result, never a fabricated match.
|
||||
if _, has := j["sonicMatch"]; has {
|
||||
t.Errorf("expected no sonicMatch key when nothing is analyzed, got %v", j["sonicMatch"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSonicPathReachesBothEndpointsAndOrdersMonotonically(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
slow, _, fast, _ := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("startSongId", slow.ID)
|
||||
q.Set("endSongId", fast.ID)
|
||||
q.Set("count", "3")
|
||||
q.Set("f", "json")
|
||||
resp := getPath(t, srv, "/rest/findSonicPath.view", q)
|
||||
j := decodeJSON(t, resp)
|
||||
matches, ok := j["sonicMatch"].([]any)
|
||||
if !ok || len(matches) != 3 {
|
||||
t.Fatalf("sonicMatch = %v, want 3 (start, mid, end)", j)
|
||||
}
|
||||
first := matches[0].(map[string]any)["entry"].(map[string]any)
|
||||
last := matches[2].(map[string]any)["entry"].(map[string]any)
|
||||
middle := matches[1].(map[string]any)["entry"].(map[string]any)
|
||||
if first["title"] != "Slow" || last["title"] != "Fast" {
|
||||
t.Fatalf("path endpoints = %v .. %v, want Slow .. Fast", first["title"], last["title"])
|
||||
}
|
||||
if middle["title"] != "Mid" {
|
||||
t.Errorf("path middle hop = %v, want Mid (the only other analyzed song, between Slow and Fast)", middle["title"])
|
||||
}
|
||||
startSim := matches[0].(map[string]any)["similarity"].(float64)
|
||||
if startSim != 1 {
|
||||
t.Errorf("similarity of start song to itself = %v, want 1", startSim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindSonicPathUnknownEndpoint(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
createTestUser(t, s, "alice", "password123", false)
|
||||
slow, _, _, _ := sonicTestLibrary(t, s)
|
||||
|
||||
srv := newSubsonicTestServer(t, s)
|
||||
q := baseParams("alice", "password123")
|
||||
q.Set("startSongId", slow.ID)
|
||||
q.Set("endSongId", "sg_doesnotexist")
|
||||
resp := getPath(t, srv, "/rest/findSonicPath.view", q)
|
||||
x := decodeXML(t, resp)
|
||||
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
|
||||
t.Fatalf("expected error 70, got %+v", x)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user