Files
vibesonic/internal/subsonic/sonic_similarity_test.go
T
owenandClaude Sonnet 5 b6acd63c2d 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
2026-08-23 08:30:20 -05:00

189 lines
6.5 KiB
Go

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)
}
}