Files
vibesonic/internal/database/audio_features_test.go
T
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

110 lines
2.9 KiB
Go

package database
import (
"database/sql"
"path/filepath"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
)
func openAudioFeaturesTestDB(t *testing.T) (*sql.DB, string) {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { db.Close() })
folder, err := CreateMusicFolder(db, "Music", "/music", "music")
if err != nil {
t.Fatal(err)
}
mf, err := CreateMediaFile(db, folder.ID, "song.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
if err != nil {
t.Fatal(err)
}
song, err := UpsertSong(db, UpsertSongInput{MediaFileID: mf.ID, Title: "Song"})
if err != nil {
t.Fatal(err)
}
return db, song.ID
}
func TestUpsertAndGetAudioFeatures(t *testing.T) {
db, songID := openAudioFeaturesTestDB(t)
f := &audiofeatures.Features{
TempoBPM: 120, TempoValid: true, Key: "C major", LoudnessLUFS: -14,
SpectralCentroidHz: 1500, SpectralRolloffHz: 4000, ZeroCrossingRate: 0.05,
Chroma: [12]float64{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
}
if err := UpsertAudioFeatures(db, songID, f); err != nil {
t.Fatalf("UpsertAudioFeatures: %v", err)
}
got, err := GetAudioFeatures(db, songID)
if err != nil {
t.Fatalf("GetAudioFeatures: %v", err)
}
if got == nil {
t.Fatal("expected a row, got nil")
}
if !got.TempoValid || got.TempoBPM != 120 {
t.Errorf("tempo = %v/%v, want 120/valid", got.TempoBPM, got.TempoValid)
}
if got.MusicalKey != "C major" {
t.Errorf("key = %q", got.MusicalKey)
}
if got.LoudnessLUFS != -14 {
t.Errorf("loudness = %v", got.LoudnessLUFS)
}
if got.Extra.SpectralCentroidHz != 1500 || got.Extra.Chroma[0] != 1 {
t.Errorf("extra = %+v", got.Extra)
}
}
func TestUpsertAudioFeaturesOverwrites(t *testing.T) {
db, songID := openAudioFeaturesTestDB(t)
if err := UpsertAudioFeatures(db, songID, &audiofeatures.Features{Key: "C major"}); err != nil {
t.Fatal(err)
}
if err := UpsertAudioFeatures(db, songID, &audiofeatures.Features{Key: "D minor"}); err != nil {
t.Fatal(err)
}
got, err := GetAudioFeatures(db, songID)
if err != nil {
t.Fatal(err)
}
if got.MusicalKey != "D minor" {
t.Errorf("key = %q, want overwrite to D minor", got.MusicalKey)
}
}
func TestGetAudioFeaturesNotAnalyzed(t *testing.T) {
db, songID := openAudioFeaturesTestDB(t)
got, err := GetAudioFeatures(db, songID)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Errorf("expected nil for an unanalyzed song, got %+v", got)
}
}
func TestListAllAudioFeatures(t *testing.T) {
db, songID := openAudioFeaturesTestDB(t)
if err := UpsertAudioFeatures(db, songID, &audiofeatures.Features{TempoValid: true, TempoBPM: 90}); err != nil {
t.Fatal(err)
}
all, err := ListAllAudioFeatures(db)
if err != nil {
t.Fatal(err)
}
if len(all) != 1 || all[0].SongID != songID {
t.Fatalf("all = %+v", all)
}
if len(all[0].Vector()) != 17 {
t.Fatalf("Vector length = %d, want 17", len(all[0].Vector()))
}
}