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:
2026-08-23 08:30:20 -05:00
co-authored by Claude Sonnet 5
parent 6fb46a989e
commit b6acd63c2d
13 changed files with 1486 additions and 4 deletions
+1 -1
View File
@@ -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)
+137
View File
@@ -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
}
+188
View File
@@ -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)
}
}