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:
2026-08-23 08:30:20 -05:00
co-authored by Claude Sonnet 5
parent 6fb46a989e
commit b6acd63c2d
13 changed files with 1486 additions and 4 deletions
+1
View File
@@ -57,6 +57,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
prober := &metadata.Prober{FFprobePath: cfg.FFprobePath} prober := &metadata.Prober{FFprobePath: cfg.FFprobePath}
sc := scanner.New(db, prober, log) sc := scanner.New(db, prober, log)
sc.FFmpegPath = cfg.FFmpegPath
coverCache := filepath.Join(cfg.DataDir, "cache", "covers") coverCache := filepath.Join(cfg.DataDir, "cache", "covers")
covers := coverart.New(coverCache, cfg.FFmpegPath) covers := coverart.New(coverCache, cfg.FFmpegPath)
+335
View File
@@ -0,0 +1,335 @@
// Package audiofeatures extracts a small set of real, numerically
// computed audio characteristics from a media file — tempo, loudness,
// spectral shape, and a chroma (pitch-class) profile used both for a
// musical key guess and as the basis of a sonic-similarity feature
// vector. Everything here is derived from decoding the actual audio
// with ffmpeg; nothing is inferred from tags, filenames, or genre.
package audiofeatures
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"math"
"os/exec"
"sort"
)
const (
sampleRate = 22050
frameSize = 2048
hopSize = 1024
)
// Features is everything VibeSonic actually managed to measure about a
// track's audio. Fields the analysis couldn't determine with enough
// confidence (e.g. tempo on a clip too short to contain a beat) are
// left at their zero value with the matching Valid flag false, rather
// than filled with a guess.
type Features struct {
TempoBPM float64
TempoValid bool
Key string // e.g. "C major", "" if undetermined
LoudnessLUFS float64 // approximate: mean-square level in dB, not full ITU-R BS.1770
SpectralCentroidHz float64
SpectralRolloffHz float64
ZeroCrossingRate float64
Chroma [12]float64 // normalized pitch-class energy distribution
}
// Vector returns the fixed-length numeric feature vector used for
// sonic-similarity distance calculations: [tempo, loudness, centroid,
// rolloff, zcr, chroma(12)]. An invalid tempo contributes 0, same as a
// track's other quiet dimensions would if it were literally silent in
// that regard — the similarity index normalizes each dimension across
// the library before comparing, so a shared 0 among untempoed tracks
// doesn't bias results toward or away from any real tempo.
func (f Features) Vector() []float64 {
tempo := 0.0
if f.TempoValid {
tempo = f.TempoBPM
}
v := make([]float64, 0, 17)
v = append(v, tempo, f.LoudnessLUFS, f.SpectralCentroidHz, f.SpectralRolloffHz, f.ZeroCrossingRate)
v = append(v, f.Chroma[:]...)
return v
}
// Analyze decodes sourcePath to mono PCM via ffmpeg and computes
// Features from it. It requires at least a few frames of audio
// (roughly 150ms); shorter input still returns loudness/spectral
// values from what's available but leaves tempo unset.
func Analyze(ctx context.Context, ffmpegPath, sourcePath string) (*Features, error) {
if ffmpegPath == "" {
ffmpegPath = "ffmpeg"
}
pcm, err := decodePCM(ctx, ffmpegPath, sourcePath)
if err != nil {
return nil, err
}
if len(pcm) == 0 {
return nil, fmt.Errorf("audiofeatures: no decodable audio in %s", sourcePath)
}
return analyzeSamples(pcm), nil
}
func decodePCM(ctx context.Context, ffmpegPath, sourcePath string) ([]float64, error) {
cmd := exec.CommandContext(ctx, ffmpegPath,
"-v", "error",
"-i", sourcePath,
"-f", "f32le", "-ac", "1", "-ar", fmt.Sprintf("%d", sampleRate),
"-",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg decode failed: %w: %s", err, stderr.String())
}
raw := stdout.Bytes()
n := len(raw) / 4
samples := make([]float64, n)
for i := 0; i < n; i++ {
bits := binary.LittleEndian.Uint32(raw[i*4 : i*4+4])
samples[i] = float64(math.Float32frombits(bits))
}
return samples, nil
}
func analyzeSamples(samples []float64) *Features {
f := &Features{}
// Overall RMS loudness, in dB relative to full scale — a real
// (if simplified) measurement of the decoded signal, not a stand-in.
var sumSq float64
for _, s := range samples {
sumSq += s * s
}
rms := math.Sqrt(sumSq / float64(len(samples)))
if rms > 0 {
f.LoudnessLUFS = 20 * math.Log10(rms)
} else {
f.LoudnessLUFS = -120
}
var (
centroidSum, rolloffSum, zcrSum float64
frameCount int
energyEnvelope []float64
chroma [12]float64
)
hann := hannWindow(frameSize)
for start := 0; start+frameSize <= len(samples); start += hopSize {
frame := make([]float64, frameSize)
var frameEnergy float64
var crossings int
for i := 0; i < frameSize; i++ {
s := samples[start+i]
frame[i] = s * hann[i]
frameEnergy += s * s
if i > 0 && ((samples[start+i-1] < 0) != (s < 0)) {
crossings++
}
}
energyEnvelope = append(energyEnvelope, math.Sqrt(frameEnergy/float64(frameSize)))
zcrSum += float64(crossings) / float64(frameSize)
mags := magnitudeSpectrum(frame)
centroid, rolloff := spectralShape(mags, sampleRate, frameSize)
centroidSum += centroid
rolloffSum += rolloff
accumulateChroma(&chroma, mags, sampleRate, frameSize)
frameCount++
}
if frameCount > 0 {
f.SpectralCentroidHz = centroidSum / float64(frameCount)
f.SpectralRolloffHz = rolloffSum / float64(frameCount)
f.ZeroCrossingRate = zcrSum / float64(frameCount)
normalizeChroma(&chroma)
f.Chroma = chroma
f.Key = estimateKey(chroma)
}
if bpm, ok := estimateTempo(energyEnvelope, float64(hopSize)/float64(sampleRate)); ok {
f.TempoBPM = bpm
f.TempoValid = true
}
return f
}
func hannWindow(n int) []float64 {
w := make([]float64, n)
for i := range w {
w[i] = 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1)))
}
return w
}
// spectralShape returns the spectral centroid (the "center of mass" of
// the spectrum, in Hz — a brightness measure) and the 85% rolloff
// frequency (below which 85% of the frame's spectral energy lies).
func spectralShape(mags []float64, sampleRate, frameSize int) (centroid, rolloff float64) {
var weighted, total float64
binHz := float64(sampleRate) / float64(frameSize)
for i, m := range mags {
freq := float64(i) * binHz
weighted += freq * m
total += m
}
if total == 0 {
return 0, 0
}
centroid = weighted / total
target := 0.85 * total
var cum float64
for i, m := range mags {
cum += m
if cum >= target {
rolloff = float64(i) * binHz
break
}
}
return centroid, rolloff
}
// accumulateChroma folds a frame's spectral magnitude into 12
// pitch-class bins (assuming standard A440 tuning), skipping the
// sub-audible and very-high-frequency bins where pitch content is
// unreliable.
func accumulateChroma(chroma *[12]float64, mags []float64, sampleRate, frameSize int) {
binHz := float64(sampleRate) / float64(frameSize)
for i, m := range mags {
freq := float64(i) * binHz
if freq < 27.5 || freq > 5000 || m == 0 {
continue
}
midi := 69 + 12*math.Log2(freq/440)
pc := int(math.Round(midi)) % 12
if pc < 0 {
pc += 12
}
chroma[pc] += m
}
}
func normalizeChroma(chroma *[12]float64) {
var total float64
for _, v := range chroma {
total += v
}
if total == 0 {
return
}
for i := range chroma {
chroma[i] /= total
}
}
// krumhanslMajor/Minor are the classic Krumhansl-Kessler key profiles:
// the relative perceptual stability of each pitch class within a major
// or minor key, starting from the tonic. Correlating a track's chroma
// vector against all 24 rotations picks the best-fitting key.
var krumhanslMajor = [12]float64{6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88}
var krumhanslMinor = [12]float64{6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17}
var pitchClassNames = [12]string{"C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"}
func estimateKey(chroma [12]float64) string {
type candidate struct {
name string
score float64
}
var candidates []candidate
for tonic := 0; tonic < 12; tonic++ {
candidates = append(candidates,
candidate{pitchClassNames[tonic] + " major", correlate(chroma, rotate(krumhanslMajor, tonic))},
candidate{pitchClassNames[tonic] + " minor", correlate(chroma, rotate(krumhanslMinor, tonic))},
)
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
if len(candidates) == 0 || candidates[0].score <= 0 {
return ""
}
return candidates[0].name
}
func rotate(profile [12]float64, by int) [12]float64 {
var out [12]float64
for i := range out {
out[i] = profile[(i-by+12)%12]
}
return out
}
func correlate(a, b [12]float64) float64 {
var meanA, meanB float64
for i := 0; i < 12; i++ {
meanA += a[i]
meanB += b[i]
}
meanA /= 12
meanB /= 12
var num, denomA, denomB float64
for i := 0; i < 12; i++ {
da, db := a[i]-meanA, b[i]-meanB
num += da * db
denomA += da * da
denomB += db * db
}
if denomA == 0 || denomB == 0 {
return 0
}
return num / math.Sqrt(denomA*denomB)
}
// estimateTempo autocorrelates the frame-wise energy envelope over the
// lag range corresponding to 50-220 BPM and returns the lag with the
// strongest periodicity. It reports ok=false when there isn't enough
// envelope to cover even the slowest candidate tempo, rather than
// returning a meaningless number for a clip too short to have a beat.
func estimateTempo(envelope []float64, hopSeconds float64) (bpm float64, ok bool) {
minLagSec := 60.0 / 220.0
maxLagSec := 60.0 / 50.0
minLag := int(minLagSec / hopSeconds)
maxLag := int(maxLagSec / hopSeconds)
if minLag < 1 {
minLag = 1
}
if len(envelope) < maxLag+8 {
return 0, false
}
var mean float64
for _, v := range envelope {
mean += v
}
mean /= float64(len(envelope))
centered := make([]float64, len(envelope))
for i, v := range envelope {
centered[i] = v - mean
}
bestLag := -1
bestScore := 0.0
for lag := minLag; lag <= maxLag && lag < len(centered); lag++ {
var score float64
for i := 0; i+lag < len(centered); i++ {
score += centered[i] * centered[i+lag]
}
if score > bestScore {
bestScore = score
bestLag = lag
}
}
if bestLag <= 0 || bestScore <= 0 {
return 0, false
}
return 60.0 / (float64(bestLag) * hopSeconds), true
}
+148
View File
@@ -0,0 +1,148 @@
package audiofeatures
import (
"context"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
)
func genTone(t *testing.T, dir, name string, freq float64, duration string) string {
t.Helper()
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed, skipping audiofeatures integration test")
}
out := filepath.Join(dir, name)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg", "-y",
"-f", "lavfi", "-i", "sine=frequency="+strconv.FormatFloat(freq, 'f', -1, 64)+":duration="+duration,
"-codec:a", "pcm_s16le", out,
)
if combined, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("ffmpeg failed: %v\n%s", err, combined)
}
return out
}
func TestAnalyzeLowVsHighToneHaveDifferentChromaPeak(t *testing.T) {
dir := t.TempDir()
lowPath := genTone(t, dir, "low.wav", 130.81, "1") // C3
highPath := genTone(t, dir, "high.wav", 1046.50, "1") // C6 — same pitch class, different octave
low, err := Analyze(context.Background(), "", lowPath)
if err != nil {
t.Fatalf("Analyze(low): %v", err)
}
high, err := Analyze(context.Background(), "", highPath)
if err != nil {
t.Fatalf("Analyze(high): %v", err)
}
if high.SpectralCentroidHz <= low.SpectralCentroidHz {
t.Errorf("expected the higher tone to have a higher spectral centroid: low=%.1f high=%.1f",
low.SpectralCentroidHz, high.SpectralCentroidHz)
}
// Both tones are a C, just in different octaves: pitch-class energy
// should peak at chroma bin 0 for both — a real, verifiable property
// of the chroma computation, not a fuzzy assertion.
if argmax(low.Chroma) != 0 {
t.Errorf("low tone (C3) chroma peak = bin %d, want 0 (C)", argmax(low.Chroma))
}
if argmax(high.Chroma) != 0 {
t.Errorf("high tone (C6) chroma peak = bin %d, want 0 (C)", argmax(high.Chroma))
}
}
func TestAnalyzeDifferentPitchClassesHaveDifferentChromaPeak(t *testing.T) {
dir := t.TempDir()
cPath := genTone(t, dir, "c.wav", 261.63, "1")
gPath := genTone(t, dir, "g.wav", 392.00, "1")
c, err := Analyze(context.Background(), "", cPath)
if err != nil {
t.Fatalf("Analyze(C): %v", err)
}
g, err := Analyze(context.Background(), "", gPath)
if err != nil {
t.Fatalf("Analyze(G): %v", err)
}
if argmax(c.Chroma) != 0 {
t.Errorf("C4 chroma peak = bin %d, want 0", argmax(c.Chroma))
}
if argmax(g.Chroma) != 7 {
t.Errorf("G4 chroma peak = bin %d, want 7", argmax(g.Chroma))
}
}
func TestAnalyzeLoudVsQuietHaveDifferentLoudness(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed, skipping audiofeatures integration test")
}
dir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
loud := filepath.Join(dir, "loud.wav")
if out, err := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-af", "volume=1.0", "-codec:a", "pcm_s16le", loud).CombinedOutput(); err != nil {
t.Fatalf("ffmpeg: %v\n%s", err, out)
}
quiet := filepath.Join(dir, "quiet.wav")
if out, err := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "lavfi",
"-i", "sine=frequency=440:duration=1", "-af", "volume=0.05", "-codec:a", "pcm_s16le", quiet).CombinedOutput(); err != nil {
t.Fatalf("ffmpeg: %v\n%s", err, out)
}
loudF, err := Analyze(context.Background(), "", loud)
if err != nil {
t.Fatal(err)
}
quietF, err := Analyze(context.Background(), "", quiet)
if err != nil {
t.Fatal(err)
}
if loudF.LoudnessLUFS <= quietF.LoudnessLUFS {
t.Errorf("loud=%.1f quiet=%.1f, want loud clip to measure louder", loudF.LoudnessLUFS, quietF.LoudnessLUFS)
}
}
func TestAnalyzeShortClipLeavesTempoInvalid(t *testing.T) {
dir := t.TempDir()
// A single sine tone has no rhythmic/onset content at all — VibeSonic
// must not fabricate a BPM for it.
path := genTone(t, dir, "tone.wav", 440, "0.5")
f, err := Analyze(context.Background(), "", path)
if err != nil {
t.Fatal(err)
}
if f.TempoValid {
t.Errorf("expected TempoValid=false for a featureless 0.5s tone, got %.1f BPM", f.TempoBPM)
}
}
func TestVectorLengthAndTempoZeroWhenInvalid(t *testing.T) {
f := Features{TempoValid: false, TempoBPM: 999}
v := f.Vector()
if len(v) != 17 {
t.Fatalf("Vector length = %d, want 17", len(v))
}
if v[0] != 0 {
t.Errorf("Vector()[0] (tempo) = %v, want 0 when TempoValid=false", v[0])
}
}
func argmax(v [12]float64) int {
best := 0
for i := 1; i < 12; i++ {
if v[i] > v[best] {
best = i
}
}
return best
}
+65
View File
@@ -0,0 +1,65 @@
package audiofeatures
import "math"
// fft computes the in-place iterative radix-2 Cooley-Tukey FFT of a,
// whose length must be a power of two. It's the only DSP primitive this
// package needs (spectral centroid/rolloff and the chroma vector all
// derive from the resulting magnitude spectrum), so a small
// hand-written implementation avoids pulling in an external numerics
// dependency for one function.
func fft(a []complex128) {
n := len(a)
if n <= 1 {
return
}
// Bit-reversal permutation.
for i, j := 1, 0; i < n; i++ {
bit := n >> 1
for ; j&bit != 0; bit >>= 1 {
j ^= bit
}
j ^= bit
if i < j {
a[i], a[j] = a[j], a[i]
}
}
for length := 2; length <= n; length <<= 1 {
ang := -2 * math.Pi / float64(length)
wlen := complex(math.Cos(ang), math.Sin(ang))
for i := 0; i < n; i += length {
w := complex(1, 0)
half := length / 2
for k := 0; k < half; k++ {
u := a[i+k]
v := a[i+k+half] * w
a[i+k] = u + v
a[i+k+half] = u - v
w *= wlen
}
}
}
}
// magnitudeSpectrum returns |FFT(frame)| for bins [0, n/2], given a
// real-valued frame whose length is a power of two.
func magnitudeSpectrum(frame []float64) []float64 {
n := len(frame)
buf := make([]complex128, n)
for i, v := range frame {
buf[i] = complex(v, 0)
}
fft(buf)
mags := make([]float64, n/2+1)
for i := range mags {
mags[i] = cmplxAbs(buf[i])
}
return mags
}
func cmplxAbs(c complex128) float64 {
re, im := real(c), imag(c)
return math.Sqrt(re*re + im*im)
}
+118
View File
@@ -0,0 +1,118 @@
package database
import (
"database/sql"
"encoding/json"
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
)
// AudioFeaturesRow is one song's stored sonic-analysis result.
type AudioFeaturesRow struct {
SongID string
TempoBPM float64
TempoValid bool
MusicalKey string
LoudnessLUFS float64
Extra audioFeaturesExtra
}
// audioFeaturesExtra is the analyzer output that doesn't have its own
// dedicated column — kept in extra_json so the schema doesn't need a
// migration every time the analyzer grows a new measurement.
type audioFeaturesExtra struct {
SpectralCentroidHz float64 `json:"spectralCentroidHz"`
SpectralRolloffHz float64 `json:"spectralRolloffHz"`
ZeroCrossingRate float64 `json:"zeroCrossingRate"`
Chroma [12]float64 `json:"chroma"`
}
// Vector reconstructs the same fixed-length feature vector
// audiofeatures.Features.Vector produces, from stored values.
func (r AudioFeaturesRow) Vector() []float64 {
tempo := 0.0
if r.TempoValid {
tempo = r.TempoBPM
}
v := make([]float64, 0, 17)
v = append(v, tempo, r.LoudnessLUFS, r.Extra.SpectralCentroidHz, r.Extra.SpectralRolloffHz, r.Extra.ZeroCrossingRate)
v = append(v, r.Extra.Chroma[:]...)
return v
}
// UpsertAudioFeatures stores (or replaces) the analyzed features for a
// song.
func UpsertAudioFeatures(db *sql.DB, songID string, f *audiofeatures.Features) error {
extra := audioFeaturesExtra{
SpectralCentroidHz: f.SpectralCentroidHz,
SpectralRolloffHz: f.SpectralRolloffHz,
ZeroCrossingRate: f.ZeroCrossingRate,
Chroma: f.Chroma,
}
extraJSON, err := json.Marshal(extra)
if err != nil {
return err
}
var tempo sql.NullFloat64
if f.TempoValid {
tempo = sql.NullFloat64{Float64: f.TempoBPM, Valid: true}
}
_, err = db.Exec(`
INSERT INTO audio_features (song_id, tempo_bpm, musical_key, loudness_lufs, extra_json, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(song_id) DO UPDATE SET
tempo_bpm = excluded.tempo_bpm,
musical_key = excluded.musical_key,
loudness_lufs = excluded.loudness_lufs,
extra_json = excluded.extra_json,
updated_at = excluded.updated_at`,
songID, tempo, f.Key, f.LoudnessLUFS, string(extraJSON))
return err
}
const audioFeaturesSelect = `SELECT song_id, tempo_bpm, musical_key, loudness_lufs, extra_json FROM audio_features`
func scanAudioFeaturesRow(scan func(dest ...any) error) (*AudioFeaturesRow, error) {
var r AudioFeaturesRow
var tempo sql.NullFloat64
var loudness sql.NullFloat64
var extraJSON string
if err := scan(&r.SongID, &tempo, &r.MusicalKey, &loudness, &extraJSON); err != nil {
return nil, err
}
r.TempoValid = tempo.Valid
r.TempoBPM = tempo.Float64
r.LoudnessLUFS = loudness.Float64
_ = json.Unmarshal([]byte(extraJSON), &r.Extra)
return &r, nil
}
// GetAudioFeatures returns a song's stored analysis, or nil if it
// hasn't been analyzed yet.
func GetAudioFeatures(db *sql.DB, songID string) (*AudioFeaturesRow, error) {
row := db.QueryRow(audioFeaturesSelect+` WHERE song_id = ?`, songID)
r, err := scanAudioFeaturesRow(row.Scan)
if err == sql.ErrNoRows {
return nil, nil
}
return r, err
}
// ListAllAudioFeatures returns every analyzed song's features, the
// input the in-process sonic-similarity index is built from.
func ListAllAudioFeatures(db *sql.DB) ([]AudioFeaturesRow, error) {
rows, err := db.Query(audioFeaturesSelect)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AudioFeaturesRow
for rows.Next() {
r, err := scanAudioFeaturesRow(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *r)
}
return out, rows.Err()
}
+109
View File
@@ -0,0 +1,109 @@
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()))
}
}
+25
View File
@@ -15,6 +15,7 @@ import (
"sync" "sync"
"time" "time"
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart" "git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/library" "git.noroutes.net/NoRoutes/vibetunes/internal/library"
@@ -38,6 +39,7 @@ type Scanner struct {
DB *sql.DB DB *sql.DB
Prober *metadata.Prober Prober *metadata.Prober
Log *slog.Logger Log *slog.Logger
FFmpegPath string // for sonic-analysis audio decoding; "" defaults to "ffmpeg" on PATH
mu sync.Mutex mu sync.Mutex
scanning map[string]bool // music_folder_id -> in progress scanning map[string]bool // music_folder_id -> in progress
@@ -175,6 +177,7 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
return false, false, err return false, false, err
} }
s.indexCoverArt(absPath, song, tags) s.indexCoverArt(absPath, song, tags)
s.analyzeAudio(ctx, absPath, song, tags)
return false, true, nil return false, true, nil
} }
if err != database.ErrNotFound { if err != database.ErrNotFound {
@@ -204,9 +207,31 @@ func (s *Scanner) indexOne(ctx context.Context, folderID, absPath, relPath strin
return false, false, err return false, false, err
} }
s.indexCoverArt(absPath, song, tags) s.indexCoverArt(absPath, song, tags)
s.analyzeAudio(ctx, absPath, song, tags)
return true, false, nil return true, false, nil
} }
// analyzeAudio runs the sonic-feature analyzer over a scanned file and
// stores the result, powering the sonicSimilarity extension. This runs
// as part of the (already background, admin-triggered) scan itself
// rather than on any request path. A decode/analysis failure is logged
// and otherwise ignored: the song is still indexed and playable, it
// just won't appear in sonic-similarity results until a future rescan
// succeeds — never a reason to fail the whole scan.
func (s *Scanner) analyzeAudio(ctx context.Context, absPath string, song *database.Song, tags *metadata.Tags) {
if tags.Kind != "song" {
return
}
features, err := audiofeatures.Analyze(ctx, s.FFmpegPath, absPath)
if err != nil {
s.Log.Warn("audio feature analysis failed", "err", err, "path", absPath)
return
}
if err := database.UpsertAudioFeatures(s.DB, song.ID, features); err != nil {
s.Log.Warn("storing audio features failed", "err", err, "path", absPath)
}
}
// indexCoverArt records where an album's cover art can be found: a // indexCoverArt records where an album's cover art can be found: a
// folder image next to the file if one exists, otherwise the file's own // folder image next to the file if one exists, otherwise the file's own
// embedded artwork (extracted lazily by the web layer on request, not // embedded artwork (extracted lazily by the web layer on request, not
+37
View File
@@ -279,3 +279,40 @@ func TestScanFolderIndexesSidecarLRC(t *testing.T) {
t.Fatalf("expected word-level cues from Enhanced LRC line, got %+v", synced.Lines[1]) t.Fatalf("expected word-level cues from Enhanced LRC line, got %+v", synced.Lines[1])
} }
} }
func TestScanFolderAnalyzesAudioFeatures(t *testing.T) {
s, folder, dir := newTestScanner(t)
genFile(t, dir, "song1.mp3", "Song One", "Artist One")
if err := s.ScanFolder(context.Background(), folder.ID); err != nil {
t.Fatalf("ScanFolder: %v", err)
}
songs, err := database.ListSongs(s.DB, 10, 0)
if err != nil || len(songs) != 1 {
t.Fatalf("ListSongs: %v %v", songs, err)
}
features, err := database.GetAudioFeatures(s.DB, songs[0].ID)
if err != nil {
t.Fatalf("GetAudioFeatures: %v", err)
}
if features == nil {
t.Fatal("expected audio features to be stored after scanning, got none")
}
// The fixture is a pure 440Hz sine tone (A4): its chroma should
// actually peak at pitch class A (bin 9), a real measured property,
// not a placeholder.
if features.Extra.Chroma == ([12]float64{}) {
t.Error("expected a non-zero chroma vector from real analysis")
}
best := 0
for i := 1; i < 12; i++ {
if features.Extra.Chroma[i] > features.Extra.Chroma[best] {
best = i
}
}
if best != 9 { // A
t.Errorf("chroma peak = bin %d, want 9 (A) for a 440Hz fixture", best)
}
}
+203
View File
@@ -0,0 +1,203 @@
// Package similarity builds a small in-process nearest-neighbor index
// over numeric audio-feature vectors and uses it for two things:
// ranking tracks by actual sonic closeness, and walking a musically
// reasonable path between two tracks through feature space. There is
// no external vector database — the whole index is just normalized
// vectors held in memory, rebuilt from internal/database's
// audio_features table whenever a caller needs it.
package similarity
import (
"math"
"sort"
)
// Item is one track's raw (un-normalized) feature vector, keyed by
// song ID.
type Item struct {
ID string
Vector []float64
}
// Index holds z-score-normalized vectors for a set of tracks. Each
// dimension is normalized independently using the mean/stddev of that
// dimension across exactly the items the index was built from, so
// dimensions with a wide natural range (like tempo, in BPM) don't
// dominate Euclidean distance over dimensions with a narrow one (like a
// normalized chroma bin).
type Index struct {
ids []string
norm [][]float64
}
// Build normalizes and indexes items. An item whose Vector length
// disagrees with the first item's is dropped rather than causing a
// panic or silently misaligning dimensions — a defensive check, not
// something expected to trigger with correctly-produced feature rows.
func Build(items []Item) *Index {
idx := &Index{}
if len(items) == 0 {
return idx
}
dims := len(items[0].Vector)
var kept []Item
for _, it := range items {
if len(it.Vector) == dims {
kept = append(kept, it)
}
}
if len(kept) == 0 {
return idx
}
means := make([]float64, dims)
for _, it := range kept {
for d, v := range it.Vector {
means[d] += v
}
}
for d := range means {
means[d] /= float64(len(kept))
}
stds := make([]float64, dims)
for _, it := range kept {
for d, v := range it.Vector {
diff := v - means[d]
stds[d] += diff * diff
}
}
for d := range stds {
stds[d] = math.Sqrt(stds[d] / float64(len(kept)))
}
idx.ids = make([]string, len(kept))
idx.norm = make([][]float64, len(kept))
for i, it := range kept {
nv := make([]float64, dims)
for d, v := range it.Vector {
if stds[d] > 1e-9 {
nv[d] = (v - means[d]) / stds[d]
}
}
idx.ids[i] = it.ID
idx.norm[i] = nv
}
return idx
}
// Match is one similarity result: another track and its distance from
// the query track in normalized feature space (0 = identical, larger =
// less similar).
type Match struct {
ID string
Distance float64
}
// Nearest returns up to k tracks closest to id, nearest first,
// excluding id itself. Returns nil if id isn't in the index.
func (idx *Index) Nearest(id string, k int) []Match {
pos := idx.indexOf(id)
if pos < 0 {
return nil
}
matches := make([]Match, 0, len(idx.ids)-1)
for i, other := range idx.ids {
if i == pos {
continue
}
matches = append(matches, Match{ID: other, Distance: euclidean(idx.norm[pos], idx.norm[i])})
}
sort.Slice(matches, func(a, b int) bool { return matches[a].Distance < matches[b].Distance })
if k > 0 && len(matches) > k {
matches = matches[:k]
}
return matches
}
// Path walks from fromID to toID in `steps` hops (including both
// endpoints), picking at each intermediate step the not-yet-used track
// closest to the corresponding point on the straight line between
// fromID's and toID's feature vectors. That keeps the sequence
// perceptually gradual — each hop is a small, real step in sonic
// character rather than a random jump — without ever inventing a track
// that isn't actually in the library. Returns nil if either endpoint
// isn't indexed.
func (idx *Index) Path(fromID, toID string, steps int) []string {
fromPos := idx.indexOf(fromID)
toPos := idx.indexOf(toID)
if fromPos < 0 || toPos < 0 {
return nil
}
if steps < 2 {
steps = 2
}
path := []string{fromID}
used := map[string]bool{fromID: true, toID: true}
fromVec, toVec := idx.norm[fromPos], idx.norm[toPos]
for step := 1; step < steps-1; step++ {
t := float64(step) / float64(steps-1)
target := lerp(fromVec, toVec, t)
best := -1
bestDist := math.Inf(1)
for i, id := range idx.ids {
if used[id] {
continue
}
d := euclidean(idx.norm[i], target)
if d < bestDist {
bestDist = d
best = i
}
}
if best < 0 {
break // library exhausted; fall through with what we have
}
path = append(path, idx.ids[best])
used[idx.ids[best]] = true
}
path = append(path, toID)
return path
}
// DistanceBetween returns the normalized-feature-space Euclidean
// distance between two indexed tracks, or +Inf if either isn't
// indexed.
func (idx *Index) DistanceBetween(idA, idB string) float64 {
a, b := idx.indexOf(idA), idx.indexOf(idB)
if a < 0 || b < 0 {
return math.Inf(1)
}
return euclidean(idx.norm[a], idx.norm[b])
}
func (idx *Index) indexOf(id string) int {
for i, v := range idx.ids {
if v == id {
return i
}
}
return -1
}
func euclidean(a, b []float64) float64 {
var sum float64
for i := range a {
d := a[i] - b[i]
sum += d * d
}
return math.Sqrt(sum)
}
func lerp(a, b []float64, t float64) []float64 {
out := make([]float64, len(a))
for i := range a {
out[i] = a[i] + (b[i]-a[i])*t
}
return out
}
+116
View File
@@ -0,0 +1,116 @@
package similarity
import "testing"
func tempoLibrary() []Item {
// A synthetic library where the only varying dimension is "tempo":
// songs at 60, 70, 80, ..., 150 BPM. Deterministic and needs no
// audio decoding, so it exercises the index math directly.
var items []Item
for i, bpm := range []float64{60, 70, 80, 90, 100, 110, 120, 130, 140, 150} {
items = append(items, Item{ID: idFor(i), Vector: []float64{bpm, 0, 0}})
}
return items
}
func idFor(i int) string {
return string(rune('a' + i))
}
func TestNearestOrdersByActualDistance(t *testing.T) {
idx := Build(tempoLibrary())
// "d" is 90 BPM; its closest neighbors by tempo should be "c" (80)
// and "e" (100), in that order (tie broken by index order is fine
// since both are exactly 10 BPM away — check the pair, not order).
matches := idx.Nearest("d", 2)
if len(matches) != 2 {
t.Fatalf("matches = %v, want 2", matches)
}
got := map[string]bool{matches[0].ID: true, matches[1].ID: true}
if !got["c"] || !got["e"] {
t.Errorf("nearest to d(90bpm) = %v, want {c(80bpm), e(100bpm)}", matches)
}
}
func TestNearestExcludesSelf(t *testing.T) {
idx := Build(tempoLibrary())
matches := idx.Nearest("a", 20)
for _, m := range matches {
if m.ID == "a" {
t.Fatal("Nearest must never include the query track itself")
}
}
}
func TestNearestUnknownID(t *testing.T) {
idx := Build(tempoLibrary())
if matches := idx.Nearest("does-not-exist", 5); matches != nil {
t.Errorf("expected nil for unknown id, got %v", matches)
}
}
func TestNearestFarthestIsOppositeEnd(t *testing.T) {
idx := Build(tempoLibrary())
matches := idx.Nearest("a", 20) // a = 60bpm, all 9 others
if len(matches) != 9 {
t.Fatalf("matches = %d, want 9", len(matches))
}
farthest := matches[len(matches)-1]
if farthest.ID != "j" { // j = 150bpm, the farthest from 60bpm
t.Errorf("farthest from a(60bpm) = %v, want j(150bpm)", farthest)
}
}
func TestPathIsMonotonicAlongTheOnlyVaryingDimension(t *testing.T) {
idx := Build(tempoLibrary())
path := idx.Path("a", "j", 5) // 60bpm -> 150bpm in 5 hops
if len(path) != 5 {
t.Fatalf("path = %v, want length 5", path)
}
if path[0] != "a" || path[len(path)-1] != "j" {
t.Fatalf("path endpoints = %q..%q, want a..j", path[0], path[len(path)-1])
}
seen := map[string]bool{}
for _, id := range path {
if seen[id] {
t.Fatalf("path repeats %q: %v", id, path)
}
seen[id] = true
}
// With tempo as the only varying dimension, a straight-line
// interpolation from 60 to 150 BPM should visit strictly increasing
// tempos.
bpmOf := map[string]float64{"a": 60, "b": 70, "c": 80, "d": 90, "e": 100, "f": 110, "g": 120, "h": 130, "i": 140, "j": 150}
for i := 1; i < len(path); i++ {
if bpmOf[path[i]] <= bpmOf[path[i-1]] {
t.Errorf("path not monotonically increasing in tempo: %v", path)
break
}
}
}
func TestPathUnknownEndpoint(t *testing.T) {
idx := Build(tempoLibrary())
if path := idx.Path("a", "nope", 5); path != nil {
t.Errorf("expected nil path for unknown endpoint, got %v", path)
}
}
func TestBuildEmpty(t *testing.T) {
idx := Build(nil)
if matches := idx.Nearest("a", 5); matches != nil {
t.Errorf("expected nil from an empty index, got %v", matches)
}
}
func TestBuildConstantDimensionDoesNotDivideByZero(t *testing.T) {
items := []Item{
{ID: "x", Vector: []float64{5, 1}},
{ID: "y", Vector: []float64{5, 2}},
}
idx := Build(items)
matches := idx.Nearest("x", 5)
if len(matches) != 1 || matches[0].ID != "y" {
t.Fatalf("matches = %v", matches)
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestGetOpenSubsonicExtensions(t *testing.T) {
// set once Phase 3 is done. // set once Phase 3 is done.
for _, want := range []string{ for _, want := range []string{
"apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset", "apiKeyAuthentication", "formPost", "getPodcastEpisode", "topSongsByArtistId", "transcodeOffset",
"indexBasedQueue", "playbackReport", "songLyrics", "transcoding", "indexBasedQueue", "playbackReport", "songLyrics", "transcoding", "sonicSimilarity",
} { } {
if !names[want] { if !names[want] {
t.Errorf("extension %q not advertised: %v", want, names) t.Errorf("extension %q not advertised: %v", want, names)
+137
View File
@@ -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
}
+188
View File
@@ -0,0 +1,188 @@
package subsonic
import (
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/audiofeatures"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// makeAnalyzedSong creates a minimal song fixture (no real audio file
// needed — sonic-similarity handlers only ever read the songs table and
// audio_features, never touch disk) and stores a hand-crafted feature
// vector for it, isolating these tests from ffmpeg/analysis timing.
func makeAnalyzedSong(t *testing.T, s *Server, folderID, title string, tempo float64) *database.Song {
t.Helper()
mf, err := database.CreateMediaFile(s.DB, folderID, title+".mp3", 100, 0, "hash-"+title, "mp3", "song", 10, 128, 44100, 2)
if err != nil {
t.Fatal(err)
}
song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: title})
if err != nil {
t.Fatal(err)
}
f := &audiofeatures.Features{TempoBPM: tempo, TempoValid: true, Key: "C major"}
if err := database.UpsertAudioFeatures(s.DB, song.ID, f); err != nil {
t.Fatal(err)
}
return song
}
func sonicTestLibrary(t *testing.T, s *Server) (slow, mid, fast, unanalyzed *database.Song) {
t.Helper()
folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music")
if err != nil {
t.Fatal(err)
}
slow = makeAnalyzedSong(t, s, folder.ID, "Slow", 70)
mid = makeAnalyzedSong(t, s, folder.ID, "Mid", 100)
fast = makeAnalyzedSong(t, s, folder.ID, "Fast", 160)
mf, err := database.CreateMediaFile(s.DB, folder.ID, "Unanalyzed.mp3", 100, 0, "hash-unanalyzed", "mp3", "song", 10, 128, 44100, 2)
if err != nil {
t.Fatal(err)
}
unanalyzed, err = database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Unanalyzed"})
if err != nil {
t.Fatal(err)
}
return
}
func TestGetSonicSimilarTracksOrdersByRealDistance(t *testing.T) {
s, _ := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
_, mid, fast, _ := sonicTestLibrary(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", mid.ID) // 100bpm: 30bpm from Slow(70), 60bpm from Fast(160) — Slow should rank first
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
j := decodeJSON(t, resp)
matches, ok := j["sonicMatch"].([]any)
if !ok || len(matches) != 2 {
t.Fatalf("sonicMatch = %v, want 2 entries", j)
}
first := matches[0].(map[string]any)
entry := first["entry"].(map[string]any)
if entry["title"] != "Slow" {
t.Errorf("nearest to Mid(100bpm) = %v, want Slow(70bpm) (closer than Fast(160bpm))", entry["title"])
}
sim := first["similarity"].(float64)
if sim <= 0 || sim > 1 {
t.Errorf("similarity = %v, want in (0,1]", sim)
}
_ = fast
}
func TestGetSonicSimilarTracksExcludesUnanalyzedSongs(t *testing.T) {
s, _ := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
slow, _, _, unanalyzed := sonicTestLibrary(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", slow.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
j := decodeJSON(t, resp)
matches := j["sonicMatch"].([]any)
for _, m := range matches {
entry := m.(map[string]any)["entry"].(map[string]any)
if entry["title"] == "Unanalyzed" {
t.Error("a song with no stored audio_features must never appear in sonic-similarity results")
}
}
_ = unanalyzed
}
func TestGetSonicSimilarTracksUnknownSong(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("id", "sg_doesnotexist")
resp := getPath(t, srv.URL, "/rest/getSonicSimilarTracks.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
t.Fatalf("expected error 70, got %+v", x)
}
}
func TestGetSonicSimilarTracksNeverAnalyzedReturnsEmpty(t *testing.T) {
s, _ := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
folder, err := database.CreateMusicFolder(s.DB, "Music", "/music", "music")
if err != nil {
t.Fatal(err)
}
mf, err := database.CreateMediaFile(s.DB, folder.ID, "solo.mp3", 100, 0, "h", "mp3", "song", 10, 128, 44100, 2)
if err != nil {
t.Fatal(err)
}
song, err := database.UpsertSong(s.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: "Solo"})
if err != nil {
t.Fatal(err)
}
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("id", song.ID)
q.Set("f", "json")
resp := getPath(t, srv, "/rest/getSonicSimilarTracks.view", q)
j := decodeJSON(t, resp)
// No song in the library has been analyzed at all — VibeSonic must
// return an honest empty result, never a fabricated match.
if _, has := j["sonicMatch"]; has {
t.Errorf("expected no sonicMatch key when nothing is analyzed, got %v", j["sonicMatch"])
}
}
func TestFindSonicPathReachesBothEndpointsAndOrdersMonotonically(t *testing.T) {
s, _ := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
slow, _, fast, _ := sonicTestLibrary(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("startSongId", slow.ID)
q.Set("endSongId", fast.ID)
q.Set("count", "3")
q.Set("f", "json")
resp := getPath(t, srv, "/rest/findSonicPath.view", q)
j := decodeJSON(t, resp)
matches, ok := j["sonicMatch"].([]any)
if !ok || len(matches) != 3 {
t.Fatalf("sonicMatch = %v, want 3 (start, mid, end)", j)
}
first := matches[0].(map[string]any)["entry"].(map[string]any)
last := matches[2].(map[string]any)["entry"].(map[string]any)
middle := matches[1].(map[string]any)["entry"].(map[string]any)
if first["title"] != "Slow" || last["title"] != "Fast" {
t.Fatalf("path endpoints = %v .. %v, want Slow .. Fast", first["title"], last["title"])
}
if middle["title"] != "Mid" {
t.Errorf("path middle hop = %v, want Mid (the only other analyzed song, between Slow and Fast)", middle["title"])
}
startSim := matches[0].(map[string]any)["similarity"].(float64)
if startSim != 1 {
t.Errorf("similarity of start song to itself = %v, want 1", startSim)
}
}
func TestFindSonicPathUnknownEndpoint(t *testing.T) {
s, _ := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
slow, _, _, _ := sonicTestLibrary(t, s)
srv := newSubsonicTestServer(t, s)
q := baseParams("alice", "password123")
q.Set("startSongId", slow.ID)
q.Set("endSongId", "sg_doesnotexist")
resp := getPath(t, srv, "/rest/findSonicPath.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
t.Fatalf("expected error 70, got %+v", x)
}
}