diff --git a/backend/autoeq.go b/backend/autoeq.go new file mode 100644 index 0000000..2efcb9e --- /dev/null +++ b/backend/autoeq.go @@ -0,0 +1,521 @@ +package backend + +import ( + "bufio" + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/20after4/configdir" +) + +const ( + autoEQIndexURL = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/INDEX.md" + autoEQBaseURL = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results/" + indexCacheTTL = 7 * 24 * time.Hour // 7 days + profileCacheTTL = 30 * 24 * time.Hour // 30 days + maxMemoryCacheSize = 20 // LRU cache size +) + +var ( + ErrProfileNotFound = errors.New("AutoEQ profile not found") + ErrInvalidFormat = errors.New("invalid AutoEQ profile format") +) + +// AutoEQProfile represents a headphone equalizer profile from AutoEQ +type AutoEQProfile struct { + Name string // Display name (e.g., "Sennheiser HD 650") + Path string // Full path in repo (e.g., "oratory1990/over-ear/Sennheiser HD 650") + Source string // Measurement source (e.g., "oratory1990") + Type string // Headphone type (e.g., "over-ear") + Preamp float64 // Preamp gain in dB + Bands [10]float64 // 10-band equalizer gains in dB +} + +// AutoEQProfileMetadata contains just the metadata without the EQ data +type AutoEQProfileMetadata struct { + Name string + Path string + Source string + Type string +} + +// AutoEQManager manages fetching and caching of AutoEQ profiles +type AutoEQManager struct { + cachePath string + timeout time.Duration + + // Memory cache (LRU) + memCache map[string]*memoryCacheEntry + memCacheMutex sync.RWMutex + memCacheLRU []string // Keys in LRU order (most recently used at end) + + // Index cache + indexCache []AutoEQProfileMetadata + indexCacheTime time.Time + indexCacheMutex sync.RWMutex +} + +type memoryCacheEntry struct { + profile *AutoEQProfile + lastAccessed time.Time +} + +// NewAutoEQManager creates a new AutoEQ manager +func NewAutoEQManager(cachePath string, timeout time.Duration) *AutoEQManager { + configdir.MakePath(cachePath) + log.Printf("Initializing AutoEQ manager: cache=%s, timeout=%v", cachePath, timeout) + return &AutoEQManager{ + cachePath: cachePath, + timeout: timeout, + memCache: make(map[string]*memoryCacheEntry), + memCacheLRU: make([]string, 0, maxMemoryCacheSize), + } +} + +// FetchIndex fetches the list of all available AutoEQ profiles +// Results are cached for 7 days +func (m *AutoEQManager) FetchIndex(ctx context.Context) ([]AutoEQProfileMetadata, error) { + // Check if index is already cached in memory + m.indexCacheMutex.RLock() + if len(m.indexCache) > 0 && time.Since(m.indexCacheTime) < indexCacheTTL { + cached := m.indexCache + m.indexCacheMutex.RUnlock() + return cached, nil + } + m.indexCacheMutex.RUnlock() + + // Try to load from disk cache + indexCachePath := filepath.Join(m.cachePath, "index.json") + if profiles, err := m.loadIndexFromDisk(indexCachePath); err == nil { + m.indexCacheMutex.Lock() + m.indexCache = profiles + m.indexCacheTime = time.Now() + m.indexCacheMutex.Unlock() + return profiles, nil + } + + // Fetch from network + profiles, err := m.fetchIndexFromNetwork(ctx) + if err != nil { + return nil, err + } + + // Cache in memory and disk + m.indexCacheMutex.Lock() + m.indexCache = profiles + m.indexCacheTime = time.Now() + m.indexCacheMutex.Unlock() + + m.saveIndexToDisk(indexCachePath, profiles) + + return profiles, nil +} + +func (m *AutoEQManager) loadIndexFromDisk(cachePath string) ([]AutoEQProfileMetadata, error) { + info, err := os.Stat(cachePath) + if err != nil { + return nil, err + } + + // Check if cache is expired + if time.Since(info.ModTime()) > indexCacheTTL { + return nil, errors.New("cache expired") + } + + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, err + } + + var profiles []AutoEQProfileMetadata + if err := json.Unmarshal(data, &profiles); err != nil { + return nil, err + } + + return profiles, nil +} + +func (m *AutoEQManager) saveIndexToDisk(cachePath string, profiles []AutoEQProfileMetadata) { + data, err := json.Marshal(profiles) + if err != nil { + log.Printf("Failed to marshal AutoEQ index: %v", err) + return + } + + if err := os.WriteFile(cachePath, data, 0644); err != nil { + log.Printf("Failed to write AutoEQ index cache: %v", err) + } +} + +func (m *AutoEQManager) fetchIndexFromNetwork(ctx context.Context) ([]AutoEQProfileMetadata, error) { + log.Printf("Fetching AutoEQ index from: %s (timeout: %v)", autoEQIndexURL, m.timeout) + + ctx, cancel := context.WithTimeout(ctx, m.timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, autoEQIndexURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Printf("HTTP request failed: %v", err) + return nil, fmt.Errorf("fetching index: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + profiles, err := m.parseIndex(resp.Body) + if err != nil { + log.Printf("Failed to parse AutoEQ index: %v", err) + return nil, err + } + + log.Printf("Successfully parsed %d profiles from AutoEQ index", len(profiles)) + return profiles, nil +} + +// parseIndex parses the INDEX.md markdown file +// Format: [Display Name](./path/to/profile) by source +var indexLinkRegex = regexp.MustCompile(`-\s*\[([^\]]+)\]\(\./([^)]+)\)`) + +func (m *AutoEQManager) parseIndex(r io.Reader) ([]AutoEQProfileMetadata, error) { + scanner := bufio.NewScanner(r) + var profiles []AutoEQProfileMetadata + lineCount := 0 + matchCount := 0 + + for scanner.Scan() { + line := scanner.Text() + lineCount++ + matches := indexLinkRegex.FindStringSubmatch(line) + if len(matches) == 3 { + matchCount++ + name := matches[1] + path := matches[2] + + // URL-decode the name to handle any encoded characters + if decodedName, err := url.QueryUnescape(name); err == nil { + name = decodedName + } + + // Extract source and type from path + // Format: source/type/name (e.g., "oratory1990/over-ear/Sennheiser HD 650") + parts := strings.Split(path, "/") + source := "" + typ := "" + if len(parts) >= 2 { + source = parts[0] + typ = parts[1] + + // URL-decode source and type to display clean text + if decodedSource, err := url.QueryUnescape(source); err == nil { + source = decodedSource + } + if decodedType, err := url.QueryUnescape(typ); err == nil { + typ = decodedType + } + } + + profiles = append(profiles, AutoEQProfileMetadata{ + Name: name, + Path: path, + Source: source, + Type: typ, + }) + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading index: %w", err) + } + + log.Printf("Parsed %d lines, found %d profile matches", lineCount, matchCount) + return profiles, nil +} + +// FetchProfile fetches a specific AutoEQ profile by its path +// Results are cached with LRU eviction +func (m *AutoEQManager) FetchProfile(ctx context.Context, path string) (*AutoEQProfile, error) { + // Check memory cache + m.memCacheMutex.RLock() + if entry, ok := m.memCache[path]; ok { + entry.lastAccessed = time.Now() + profile := entry.profile + m.memCacheMutex.RUnlock() + m.updateLRU(path) + return profile, nil + } + m.memCacheMutex.RUnlock() + + // Check disk cache + profile, err := m.loadProfileFromDisk(path) + if err == nil { + m.addToMemoryCache(path, profile) + return profile, nil + } + + // Fetch from network + profile, err = m.fetchProfileFromNetwork(ctx, path) + if err != nil { + return nil, err + } + + // Cache in memory and disk + m.addToMemoryCache(path, profile) + m.saveProfileToDisk(path, profile) + + return profile, nil +} + +func (m *AutoEQManager) loadProfileFromDisk(path string) (*AutoEQProfile, error) { + cacheKey := m.profileCacheKey(path) + cachePath := filepath.Join(m.cachePath, cacheKey+".json") + + info, err := os.Stat(cachePath) + if err != nil { + return nil, err + } + + // Check if cache is expired + if time.Since(info.ModTime()) > profileCacheTTL { + os.Remove(cachePath) + return nil, errors.New("cache expired") + } + + data, err := os.ReadFile(cachePath) + if err != nil { + return nil, err + } + + var profile AutoEQProfile + if err := json.Unmarshal(data, &profile); err != nil { + return nil, err + } + + return &profile, nil +} + +func (m *AutoEQManager) saveProfileToDisk(path string, profile *AutoEQProfile) { + cacheKey := m.profileCacheKey(path) + cachePath := filepath.Join(m.cachePath, cacheKey+".json") + + data, err := json.Marshal(profile) + if err != nil { + log.Printf("Failed to marshal AutoEQ profile: %v", err) + return + } + + if err := os.WriteFile(cachePath, data, 0644); err != nil { + log.Printf("Failed to write AutoEQ profile cache: %v", err) + } +} + +func (m *AutoEQManager) profileCacheKey(path string) string { + hash := md5.Sum([]byte(path)) + return hex.EncodeToString(hash[:]) +} + +func (m *AutoEQManager) fetchProfileFromNetwork(ctx context.Context, path string) (*AutoEQProfile, error) { + // URL-decode the path first (INDEX.md contains HTML-encoded paths like %20 for spaces) + decodedPath, err := url.QueryUnescape(path) + if err != nil { + log.Printf("Failed to decode path %s: %v", path, err) + decodedPath = path // fallback to original + } + + // Extract the headphone name from the path (last component) + // Path format: "source/type/Headphone Name" + pathComponents := strings.Split(decodedPath, "/") + if len(pathComponents) == 0 { + return nil, fmt.Errorf("invalid path format: %s", path) + } + headphoneName := pathComponents[len(pathComponents)-1] + + // Construct the URL by properly encoding each path component + for i, component := range pathComponents { + pathComponents[i] = url.PathEscape(component) + } + encodedPath := strings.Join(pathComponents, "/") + + // The file is named "{HeadphoneName} FixedBandEQ.txt" + encodedFileName := url.PathEscape(headphoneName + " FixedBandEQ.txt") + profileURL := autoEQBaseURL + encodedPath + "/" + encodedFileName + log.Printf("Fetching profile from: %s", profileURL) + + ctx, cancel := context.WithTimeout(ctx, m.timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, profileURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching profile: %w", err) + } + defer resp.Body.Close() + + log.Printf("Profile fetch response: %d", resp.StatusCode) + if resp.StatusCode == http.StatusNotFound { + return nil, ErrProfileNotFound + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + return m.parseProfile(path, resp.Body) +} + +// parseProfile parses the FixedBandEQ.txt file +// Format: +// Preamp: -6.0 dB +// Filter 1: ON PK Fc 31 Hz Gain 5.0 dB Q 0.70 +// ... (10 filters total) +var preampRegex = regexp.MustCompile(`Preamp:\s*([-+]?\d+\.?\d*)\s*dB`) +var filterRegex = regexp.MustCompile(`Filter\s+\d+:.*?Fc\s+(\d+)\s+Hz.*?Gain\s+([-+]?\d+\.?\d*)\s*dB`) + +func (m *AutoEQManager) parseProfile(path string, r io.Reader) (*AutoEQProfile, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("reading profile: %w", err) + } + + content := string(data) + + // Parse preamp + preampMatch := preampRegex.FindStringSubmatch(content) + if len(preampMatch) < 2 { + return nil, fmt.Errorf("%w: preamp not found", ErrInvalidFormat) + } + + preamp, err := strconv.ParseFloat(preampMatch[1], 64) + if err != nil { + return nil, fmt.Errorf("%w: invalid preamp value", ErrInvalidFormat) + } + + // Parse filters + filterMatches := filterRegex.FindAllStringSubmatch(content, -1) + if len(filterMatches) != 10 { + return nil, fmt.Errorf("%w: expected 10 filters, found %d", ErrInvalidFormat, len(filterMatches)) + } + + var bands [10]float64 + expectedFreqs := []int{31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000} + + for i, match := range filterMatches { + if len(match) < 3 { + return nil, fmt.Errorf("%w: invalid filter %d", ErrInvalidFormat, i+1) + } + + freq, err := strconv.Atoi(match[1]) + if err != nil { + return nil, fmt.Errorf("%w: invalid frequency in filter %d", ErrInvalidFormat, i+1) + } + + // Verify frequency matches expected (with tolerance for rounding) + if freq != expectedFreqs[i] { + log.Printf("Warning: AutoEQ filter %d has unexpected frequency %d Hz (expected %d Hz)", + i+1, freq, expectedFreqs[i]) + } + + gain, err := strconv.ParseFloat(match[2], 64) + if err != nil { + return nil, fmt.Errorf("%w: invalid gain in filter %d", ErrInvalidFormat, i+1) + } + + bands[i] = gain + } + + // Extract name and metadata from path + // URL-decode the path first to get clean names + decodedPath, err := url.QueryUnescape(path) + if err != nil { + decodedPath = path // fallback to original if decode fails + } + + parts := strings.Split(decodedPath, "/") + name := decodedPath + source := "" + typ := "" + + if len(parts) >= 3 { + name = parts[len(parts)-1] + source = parts[0] + typ = parts[1] + } + + return &AutoEQProfile{ + Name: name, + Path: path, + Source: source, + Type: typ, + Preamp: preamp, + Bands: bands, + }, nil +} + +func (m *AutoEQManager) addToMemoryCache(path string, profile *AutoEQProfile) { + m.memCacheMutex.Lock() + defer m.memCacheMutex.Unlock() + + // If already in cache, just update + if _, exists := m.memCache[path]; exists { + m.memCache[path].profile = profile + m.memCache[path].lastAccessed = time.Now() + return + } + + // Evict LRU if at capacity + if len(m.memCache) >= maxMemoryCacheSize { + lruKey := m.memCacheLRU[0] + delete(m.memCache, lruKey) + m.memCacheLRU = m.memCacheLRU[1:] + } + + // Add new entry + m.memCache[path] = &memoryCacheEntry{ + profile: profile, + lastAccessed: time.Now(), + } + m.memCacheLRU = append(m.memCacheLRU, path) +} + +func (m *AutoEQManager) updateLRU(path string) { + m.memCacheMutex.Lock() + defer m.memCacheMutex.Unlock() + + // Find and move to end + for i, key := range m.memCacheLRU { + if key == path { + m.memCacheLRU = append(m.memCacheLRU[:i], m.memCacheLRU[i+1:]...) + m.memCacheLRU = append(m.memCacheLRU, path) + break + } + } +} + +// InterpolateTo15Band converts this profile's 10-band EQ to Supersonic's 15-band format +func (p *AutoEQProfile) InterpolateTo15Band() [15]float64 { + return InterpolateAutoEQTo15Band(p.Bands) +} diff --git a/backend/autoeq_interpolate.go b/backend/autoeq_interpolate.go new file mode 100644 index 0000000..81dde07 --- /dev/null +++ b/backend/autoeq_interpolate.go @@ -0,0 +1,173 @@ +package backend + +import "math" + +// AutoEQ uses 10 bands at these frequencies (in Hz) +var autoEQFreqs = []float64{31.25, 62.5, 125, 250, 500, 1000, 2000, 4000, 8000, 16000} + +// Supersonic uses 15 bands at these frequencies (in Hz) +var supersonicFreqs = []float64{25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000} + +// InterpolateAutoEQTo15Band converts a 10-band AutoEQ profile to Supersonic's 15-band ISO equalizer. +// Uses logarithmic frequency positioning with linear dB interpolation. +// +// Parameters: +// - autoEQGains: Array of 10 gain values (dB) from AutoEQ at 31, 62, 125, 250, 500, 1k, 2k, 4k, 8k, 16k Hz +// +// Returns: +// - Array of 15 gain values (dB) for Supersonic at 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k, 6.3k, 10k, 16k Hz +func InterpolateAutoEQTo15Band(autoEQGains [10]float64) [15]float64 { + var result [15]float64 + + for i, targetFreq := range supersonicFreqs { + // Find the surrounding AutoEQ bands for this target frequency + lowerIdx, upperIdx := findSurroundingBands(targetFreq) + + if lowerIdx == upperIdx { + // Exact match - use the AutoEQ gain directly + result[i] = autoEQGains[lowerIdx] + } else if lowerIdx == -1 { + // Target frequency is below the lowest AutoEQ band (25 Hz < 31.25 Hz) + // Extrapolate using the first two AutoEQ bands + result[i] = extrapolateBelow(targetFreq, autoEQGains) + } else if upperIdx == -1 { + // Target frequency is above the highest AutoEQ band (should not happen with our ranges) + // Use the highest AutoEQ gain + result[i] = autoEQGains[len(autoEQGains)-1] + } else { + // Interpolate between two AutoEQ bands + fLow := autoEQFreqs[lowerIdx] + fHigh := autoEQFreqs[upperIdx] + gLow := autoEQGains[lowerIdx] + gHigh := autoEQGains[upperIdx] + + // Calculate logarithmic position between the two bands + // t = log(targetFreq/fLow) / log(fHigh/fLow) + t := math.Log(targetFreq/fLow) / math.Log(fHigh/fLow) + + // Linear interpolation of gain values + result[i] = gLow + t*(gHigh-gLow) + } + } + + return result +} + +// findSurroundingBands finds the AutoEQ band indices that surround the target frequency. +// Returns (idx, idx) if there's an exact match, (lowerIdx, upperIdx) if between bands, +// (-1, 0) if below all bands, or (lastIdx, -1) if above all bands. +func findSurroundingBands(targetFreq float64) (lowerIdx, upperIdx int) { + const epsilon = 0.01 // Tolerance for floating point comparison + + // Check if below all bands + if targetFreq < autoEQFreqs[0]-epsilon { + return -1, 0 + } + + // Check if above all bands + if targetFreq > autoEQFreqs[len(autoEQFreqs)-1]+epsilon { + return len(autoEQFreqs) - 1, -1 + } + + // Find surrounding bands + for i := 0; i < len(autoEQFreqs)-1; i++ { + // Check for exact match + if math.Abs(targetFreq-autoEQFreqs[i]) < epsilon { + return i, i + } + + // Check if between this band and the next + if targetFreq > autoEQFreqs[i] && targetFreq < autoEQFreqs[i+1] { + return i, i + 1 + } + } + + // Check for exact match with last band + if math.Abs(targetFreq-autoEQFreqs[len(autoEQFreqs)-1]) < epsilon { + return len(autoEQFreqs) - 1, len(autoEQFreqs) - 1 + } + + // Should not reach here with valid input + return len(autoEQFreqs) - 1, -1 +} + +// extrapolateBelow extrapolates the gain for frequencies below the lowest AutoEQ band. +// Uses the slope between the first two AutoEQ bands. +func extrapolateBelow(targetFreq float64, autoEQGains [10]float64) float64 { + // Use the slope between the first two bands to extrapolate + f1 := autoEQFreqs[0] + f2 := autoEQFreqs[1] + g1 := autoEQGains[0] + g2 := autoEQGains[1] + + // Calculate the slope in log-frequency space + slope := (g2 - g1) / math.Log(f2/f1) + + // Extrapolate + return g1 + slope*math.Log(targetFreq/f1) +} + +// Interpolate15BandTo10Band converts a 15-band ISO equalizer to 10-band format. +// Uses logarithmic frequency positioning with linear dB interpolation. +func Interpolate15BandTo10Band(gains15Band [15]float64) [10]float64 { + var result [10]float64 + + for i, targetFreq := range autoEQFreqs { + // Find the surrounding 15-band frequencies for this target + lowerIdx, upperIdx := findSurrounding15Bands(targetFreq) + + if lowerIdx == upperIdx { + // Exact match + result[i] = gains15Band[lowerIdx] + } else if lowerIdx == -1 { + // Below lowest band - use first band value + result[i] = gains15Band[0] + } else if upperIdx == -1 { + // Above highest band - use last band value + result[i] = gains15Band[len(gains15Band)-1] + } else { + // Interpolate between two 15-band frequencies + fLow := supersonicFreqs[lowerIdx] + fHigh := supersonicFreqs[upperIdx] + gLow := gains15Band[lowerIdx] + gHigh := gains15Band[upperIdx] + + // Logarithmic position + t := math.Log(targetFreq/fLow) / math.Log(fHigh/fLow) + + // Linear interpolation of gain + result[i] = gLow + t*(gHigh-gLow) + } + } + + return result +} + +// findSurrounding15Bands finds the 15-band indices that surround the target frequency +func findSurrounding15Bands(targetFreq float64) (lowerIdx, upperIdx int) { + const epsilon = 0.01 + + if targetFreq < supersonicFreqs[0]-epsilon { + return -1, 0 + } + + if targetFreq > supersonicFreqs[len(supersonicFreqs)-1]+epsilon { + return len(supersonicFreqs) - 1, -1 + } + + for i := 0; i < len(supersonicFreqs)-1; i++ { + if math.Abs(targetFreq-supersonicFreqs[i]) < epsilon { + return i, i + } + + if targetFreq > supersonicFreqs[i] && targetFreq < supersonicFreqs[i+1] { + return i, i + 1 + } + } + + if math.Abs(targetFreq-supersonicFreqs[len(supersonicFreqs)-1]) < epsilon { + return len(supersonicFreqs) - 1, len(supersonicFreqs) - 1 + } + + return len(supersonicFreqs) - 1, -1 +} diff --git a/backend/autoeq_interpolate_test.go b/backend/autoeq_interpolate_test.go new file mode 100644 index 0000000..1eeb7e7 --- /dev/null +++ b/backend/autoeq_interpolate_test.go @@ -0,0 +1,180 @@ +package backend + +import ( + "math" + "testing" +) + +func TestInterpolateAutoEQTo15Band_FlatProfile(t *testing.T) { + // Test with a flat profile (all zeros) + flatProfile := [10]float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + result := InterpolateAutoEQTo15Band(flatProfile) + + for i, gain := range result { + if math.Abs(gain) > 0.001 { + t.Errorf("Flat profile should result in zero gains, got %f at index %d", gain, i) + } + } +} + +func TestInterpolateAutoEQTo15Band_ExactMatches(t *testing.T) { + // Test that exact frequency matches use the AutoEQ gain directly + // AutoEQ bands: 31, 62, 125, 250, 500, 1k, 2k, 4k, 8k, 16k Hz + // Supersonic bands: 25, 40, 63, 100, 160, 250, 400, 630, 1k, 1.6k, 2.5k, 4k, 6.3k, 10k, 16k Hz + // Exact matches: 250 (idx 5→3), 1k (idx 5→8), 4k (idx 7→11), 16k (idx 9→14) + + testProfile := [10]float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} + result := InterpolateAutoEQTo15Band(testProfile) + + tests := []struct { + supersonicIdx int + autoEQIdx int + expectedGain float64 + }{ + {5, 3, 4}, // 250 Hz + {8, 5, 6}, // 1000 Hz + {11, 7, 8}, // 4000 Hz + {14, 9, 10}, // 16000 Hz + } + + for _, tt := range tests { + if math.Abs(result[tt.supersonicIdx]-tt.expectedGain) > 0.001 { + t.Errorf("Exact match at index %d (AutoEQ idx %d): expected %f, got %f", + tt.supersonicIdx, tt.autoEQIdx, tt.expectedGain, result[tt.supersonicIdx]) + } + } +} + +func TestInterpolateAutoEQTo15Band_Interpolation(t *testing.T) { + // Test interpolation between bands + // Use a profile with known values to verify interpolation + testProfile := [10]float64{0, 0, 0, 0, 0, 10, 0, 0, 0, 0} + // Only 1000 Hz (index 5) has gain of 10 dB + + result := InterpolateAutoEQTo15Band(testProfile) + + // Check that 1000 Hz (index 8) has the exact value + if math.Abs(result[8]-10) > 0.001 { + t.Errorf("1000 Hz should be 10 dB, got %f", result[8]) + } + + // Check that nearby frequencies are interpolated (should be between 0 and 10) + // 630 Hz (index 7) should be between 500 Hz (0 dB) and 1000 Hz (10 dB) + if result[7] < 0 || result[7] > 10 { + t.Errorf("630 Hz interpolation out of range: %f", result[7]) + } + + // 1600 Hz (index 9) should be between 1000 Hz (10 dB) and 2000 Hz (0 dB) + if result[9] < 0 || result[9] > 10 { + t.Errorf("1600 Hz interpolation out of range: %f", result[9]) + } +} + +func TestInterpolateAutoEQTo15Band_Extrapolation(t *testing.T) { + // Test extrapolation for 25 Hz (below lowest AutoEQ band of 31.25 Hz) + testProfile := [10]float64{5, 10, 0, 0, 0, 0, 0, 0, 0, 0} + // 31.25 Hz = 5 dB, 62.5 Hz = 10 dB + + result := InterpolateAutoEQTo15Band(testProfile) + + // 25 Hz should be extrapolated below 31.25 Hz + // Since the slope is positive (5 to 10), 25 Hz should be < 5 dB + if result[0] > 5 { + t.Errorf("25 Hz extrapolation should be < 5 dB, got %f", result[0]) + } + + // It should be a reasonable value (not too extreme) + if math.Abs(result[0]) > 20 { + t.Errorf("25 Hz extrapolation too extreme: %f", result[0]) + } +} + +func TestInterpolateAutoEQTo15Band_RealProfile(t *testing.T) { + // Test with a realistic profile shape (bass and treble boost) + // Approximating a V-shaped response + testProfile := [10]float64{6, 5, 3, 1, 0, 0, 1, 3, 5, 6} + + result := InterpolateAutoEQTo15Band(testProfile) + + // Verify output is reasonable + if len(result) != 15 { + t.Errorf("Expected 15 bands, got %d", len(result)) + } + + // Check that interpolated values are between neighboring AutoEQ values + // For example, 40 Hz (index 1) should be between 31.25 Hz and 62.5 Hz values + if result[1] < math.Min(testProfile[0], testProfile[1])-1 || + result[1] > math.Max(testProfile[0], testProfile[1])+1 { + t.Errorf("40 Hz interpolation out of reasonable range: %f (between %f and %f)", + result[1], testProfile[0], testProfile[1]) + } +} + +func TestInterpolateAutoEQTo15Band_MonotonicPreservation(t *testing.T) { + // Test that monotonic sections are preserved + // If AutoEQ gains are monotonically increasing, interpolated values should also increase + monotonicProfile := [10]float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + + result := InterpolateAutoEQTo15Band(monotonicProfile) + + // Check general increasing trend (allowing for some extrapolation variance at edges) + for i := 2; i < len(result)-1; i++ { + if result[i] < result[i-1]-0.5 { + t.Errorf("Monotonicity not preserved at index %d: %f < %f", + i, result[i], result[i-1]) + } + } +} + +func TestFindSurroundingBands_ExactMatches(t *testing.T) { + tests := []struct { + freq float64 + expectedLower int + expectedUpper int + }{ + {31.25, 0, 0}, + {62.5, 1, 1}, + {125, 2, 2}, + {250, 3, 3}, + {1000, 5, 5}, + {4000, 7, 7}, + {16000, 9, 9}, + } + + for _, tt := range tests { + lower, upper := findSurroundingBands(tt.freq) + if lower != tt.expectedLower || upper != tt.expectedUpper { + t.Errorf("findSurroundingBands(%f): expected (%d, %d), got (%d, %d)", + tt.freq, tt.expectedLower, tt.expectedUpper, lower, upper) + } + } +} + +func TestFindSurroundingBands_BetweenBands(t *testing.T) { + tests := []struct { + freq float64 + expectedLower int + expectedUpper int + }{ + {40, 0, 1}, // Between 31.25 and 62.5 + {100, 1, 2}, // Between 62.5 and 125 + {500, 4, 4}, // Exact match at 500 + {2000, 6, 6}, // Exact match at 2000 + {10000, 8, 9}, // Between 8000 and 16000 + } + + for _, tt := range tests { + lower, upper := findSurroundingBands(tt.freq) + if lower != tt.expectedLower || upper != tt.expectedUpper { + t.Errorf("findSurroundingBands(%f): expected (%d, %d), got (%d, %d)", + tt.freq, tt.expectedLower, tt.expectedUpper, lower, upper) + } + } +} + +func TestFindSurroundingBands_BelowRange(t *testing.T) { + lower, upper := findSurroundingBands(25) + if lower != -1 || upper != 0 { + t.Errorf("findSurroundingBands(25): expected (-1, 0), got (%d, %d)", lower, upper) + } +} diff --git a/backend/config.go b/backend/config.go index fa94c1a..6883f21 100644 --- a/backend/config.go +++ b/backend/config.go @@ -135,8 +135,12 @@ type LocalPlaybackConfig struct { InMemoryCacheSizeMB int Volume int EqualizerEnabled bool + EqualizerType string // "ISO10Band" or "ISO15Band" EqualizerPreamp float64 GraphicEqualizerBands []float64 + ActiveEQPresetName string // Name of currently selected EQ preset + AutoEQProfilePath string // Path to applied AutoEQ profile (e.g., "oratory1990/over-ear/Sennheiser HD 650") + AutoEQProfileName string // Display name of applied profile (e.g., "Sennheiser HD 650") PauseFade bool } @@ -269,6 +273,7 @@ func DefaultConfig(appVersionTag string) *Config { InMemoryCacheSizeMB: 30, Volume: 100, EqualizerEnabled: false, + EqualizerType: "ISO15Band", EqualizerPreamp: 0, GraphicEqualizerBands: make([]float64, 15), PauseFade: true, diff --git a/backend/eqpresets.go b/backend/eqpresets.go new file mode 100644 index 0000000..28d90b0 --- /dev/null +++ b/backend/eqpresets.go @@ -0,0 +1,150 @@ +package backend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +const eqPresetsDir = "eq_presets" + +// EQPreset represents an equalizer preset that can be saved/loaded +type EQPreset struct { + Name string `json:"name"` + Type string `json:"type"` // "ISO10Band" or "ISO15Band" + Preamp float64 `json:"preamp"` + Bands []float64 `json:"bands"` + IsBuiltin bool `json:"-"` // not saved to file, determined at load time +} + +// EQPresetManager handles loading and saving EQ presets +type EQPresetManager struct { + presetsDir string + builtinPresets []EQPreset +} + +// NewEQPresetManager creates a new preset manager +func NewEQPresetManager(configDir string) *EQPresetManager { + return &EQPresetManager{ + presetsDir: filepath.Join(configDir, eqPresetsDir), + builtinPresets: getBuiltinPresets(), + } +} + +// getBuiltinPresets returns the built-in equalizer presets +func getBuiltinPresets() []EQPreset { + return []EQPreset{ + {Name: "Flat", Type: "ISO15Band", Preamp: 0, Bands: []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, IsBuiltin: true}, + {Name: "Rock", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 3, 1, -1, -1, 0, 2, 3, 4, 4, 4, 3, 2, 2}, IsBuiltin: true}, + {Name: "Pop", Type: "ISO15Band", Preamp: 0, Bands: []float64{-1, -1, 0, 2, 4, 4, 2, 0, -1, -1, 0, 1, 2, 3, 3}, IsBuiltin: true}, + {Name: "Jazz", Type: "ISO15Band", Preamp: 0, Bands: []float64{4, 3, 1, 2, -2, -2, 0, 2, 3, 3, 3, 4, 4, 4, 4}, IsBuiltin: true}, + {Name: "Classical", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 3, 2, -1, -1, 0, 2, 3, 3, 3, 2, 2, 2, -1}, IsBuiltin: true}, + {Name: "Bass Boost", Type: "ISO15Band", Preamp: 0, Bands: []float64{6, 5, 4, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, IsBuiltin: true}, + {Name: "Treble Boost", Type: "ISO15Band", Preamp: 0, Bands: []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 4, 5, 6, 6}, IsBuiltin: true}, + {Name: "Vocal", Type: "ISO15Band", Preamp: 0, Bands: []float64{-2, -3, -3, 1, 4, 4, 4, 3, 2, 1, 0, -1, -2, -2, -3}, IsBuiltin: true}, + {Name: "Electronic", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 2, 0, -2, -2, 0, 2, 3, 4, 4, 3, 4, 4, 3}, IsBuiltin: true}, + {Name: "Acoustic", Type: "ISO15Band", Preamp: 0, Bands: []float64{5, 4, 3, 1, 2, 1, 1, 2, 2, 2, 1, 2, 2, 3, 2}, IsBuiltin: true}, + {Name: "R&B", Type: "ISO15Band", Preamp: 0, Bands: []float64{3, 6, 5, 2, -2, -2, 2, 3, 2, 2, 3, 3, 3, 3, 4}, IsBuiltin: true}, + {Name: "Loudness", Type: "ISO15Band", Preamp: 0, Bands: []float64{6, 5, 3, 0, -1, -1, -1, -1, 0, 1, 2, 4, 5, 5, 3}, IsBuiltin: true}, + } +} + +// LoadPresets loads all presets (builtin + user-defined) +func (m *EQPresetManager) LoadPresets() ([]EQPreset, error) { + // Start with builtin presets + presets := make([]EQPreset, len(m.builtinPresets)) + copy(presets, m.builtinPresets) + + // Create presets directory if it doesn't exist + if err := os.MkdirAll(m.presetsDir, 0o755); err != nil { + return presets, err + } + + // Load user presets + entries, err := os.ReadDir(m.presetsDir) + if err != nil { + return presets, nil // Return builtins if can't read dir + } + + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + + presetPath := filepath.Join(m.presetsDir, entry.Name()) + data, err := os.ReadFile(presetPath) + if err != nil { + continue // Skip invalid files + } + + var preset EQPreset + if err := json.Unmarshal(data, &preset); err != nil { + continue // Skip invalid JSON + } + + preset.IsBuiltin = false + presets = append(presets, preset) + } + + // Sort: builtins first, then user presets alphabetically + sort.Slice(presets, func(i, j int) bool { + if presets[i].IsBuiltin != presets[j].IsBuiltin { + return presets[i].IsBuiltin + } + return presets[i].Name < presets[j].Name + }) + + return presets, nil +} + +// SavePreset saves a preset to disk +func (m *EQPresetManager) SavePreset(preset EQPreset) error { + if preset.IsBuiltin { + return fmt.Errorf("cannot overwrite builtin preset") + } + + // Create presets directory if it doesn't exist + if err := os.MkdirAll(m.presetsDir, 0o755); err != nil { + return err + } + + // Sanitize filename + filename := sanitizeFilename(preset.Name) + ".json" + presetPath := filepath.Join(m.presetsDir, filename) + + data, err := json.MarshalIndent(preset, "", " ") + if err != nil { + return err + } + + return os.WriteFile(presetPath, data, 0o644) +} + +// DeletePreset deletes a user preset +func (m *EQPresetManager) DeletePreset(name string) error { + // Check if it's a builtin preset + for _, bp := range m.builtinPresets { + if bp.Name == name { + return fmt.Errorf("cannot delete builtin preset") + } + } + + filename := sanitizeFilename(name) + ".json" + presetPath := filepath.Join(m.presetsDir, filename) + + return os.Remove(presetPath) +} + +// sanitizeFilename removes characters that aren't safe for filenames +func sanitizeFilename(name string) string { + // Simple sanitization - could be enhanced + var result []rune + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == ' ' { + result = append(result, r) + } + } + return string(result) +} diff --git a/backend/player/mpv/equalizer.go b/backend/player/mpv/equalizer.go index e3a1eaa..36c59d0 100644 --- a/backend/player/mpv/equalizer.go +++ b/backend/player/mpv/equalizer.go @@ -120,3 +120,49 @@ func (w WidthType) String() string { } return "x" // not reached } + +type ISO10BandEqualizer struct { + Disabled bool + EQPreamp float64 + BandGains [10]float64 +} + +var ( + iso10Bands = []string{"31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"} + iso10FMult = 2.0 // Octave doubling +) + +var _ Equalizer = (*ISO10BandEqualizer)(nil) + +func (i *ISO10BandEqualizer) IsEnabled() bool { + return !i.Disabled +} + +func (i *ISO10BandEqualizer) Preamp() float64 { + return i.EQPreamp +} + +func (i *ISO10BandEqualizer) Curve() EqualizerCurve { + fC := float64(31) + curve := make([]EqualizerBand, 0, len(i.BandGains)) + for _, bandGain := range i.BandGains { + curve = append(curve, EqualizerBand{ + Frequency: int(math.Round(fC)), + Width: 1.0, + WidthType: WidthTypeOctave, + Gain: bandGain, + }) + fC *= iso10FMult + } + return curve +} + +func (*ISO10BandEqualizer) BandFrequencies() []string { + ret := make([]string, len(iso10Bands)) + copy(ret, iso10Bands) + return ret +} + +func (*ISO10BandEqualizer) Type() string { + return "ISO10Band" +}