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() }