Add 10-band equalizer support and AutoEQ integration

This commit is contained in:
Gianluca Boiano
2026-02-06 02:50:39 +01:00
parent 2e33b81914
commit 0097b0a052
6 changed files with 1075 additions and 0 deletions
+521
View File
@@ -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)
}
+173
View File
@@ -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
}
+180
View File
@@ -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)
}
}
+5
View File
@@ -135,8 +135,12 @@ type LocalPlaybackConfig struct {
InMemoryCacheSizeMB int InMemoryCacheSizeMB int
Volume int Volume int
EqualizerEnabled bool EqualizerEnabled bool
EqualizerType string // "ISO10Band" or "ISO15Band"
EqualizerPreamp float64 EqualizerPreamp float64
GraphicEqualizerBands []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 PauseFade bool
} }
@@ -269,6 +273,7 @@ func DefaultConfig(appVersionTag string) *Config {
InMemoryCacheSizeMB: 30, InMemoryCacheSizeMB: 30,
Volume: 100, Volume: 100,
EqualizerEnabled: false, EqualizerEnabled: false,
EqualizerType: "ISO15Band",
EqualizerPreamp: 0, EqualizerPreamp: 0,
GraphicEqualizerBands: make([]float64, 15), GraphicEqualizerBands: make([]float64, 15),
PauseFade: true, PauseFade: true,
+150
View File
@@ -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)
}
+46
View File
@@ -120,3 +120,49 @@ func (w WidthType) String() string {
} }
return "x" // not reached 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"
}