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 }