Add 10-band equalizer support and AutoEQ integration
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user