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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user