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
189 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|