Files
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

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
}