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