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
138 lines
3.8 KiB
Go
138 lines
3.8 KiB
Go
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
|
|
}
|