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 }