Merge pull request #835 from M0Rf30/feat/equalizer-improvements
Add equalizer preset management and AutoEQ integration
This commit is contained in:
+42
-15
@@ -43,17 +43,19 @@ var (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
Config *Config
|
||||
ServerManager *ServerManager
|
||||
LyricsManager *LyricsManager
|
||||
ImageManager *ImageManager
|
||||
AudioCache *AudioCache
|
||||
PlaybackManager *PlaybackManager
|
||||
LocalPlayer *mpv.Player
|
||||
UpdateChecker UpdateChecker
|
||||
MPRISHandler *MPRISHandler
|
||||
WinSMTC *windows.SMTC
|
||||
ipcServer ipc.IPCServer
|
||||
Config *Config
|
||||
ServerManager *ServerManager
|
||||
LyricsManager *LyricsManager
|
||||
ImageManager *ImageManager
|
||||
AudioCache *AudioCache
|
||||
AutoEQManager *AutoEQManager
|
||||
EQPresetManager *EQPresetManager
|
||||
PlaybackManager *PlaybackManager
|
||||
LocalPlayer *mpv.Player
|
||||
UpdateChecker UpdateChecker
|
||||
MPRISHandler *MPRISHandler
|
||||
WinSMTC *windows.SMTC
|
||||
ipcServer ipc.IPCServer
|
||||
|
||||
// UI callbacks to be set in main
|
||||
OnReactivate func()
|
||||
@@ -165,6 +167,11 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
|
||||
fetch = NewLrcLibFetcher(a.cacheDir, a.Config.Application.CustomLrcLibUrl, timeout)
|
||||
}
|
||||
a.LyricsManager = NewLyricsManager(a.ServerManager, fetch)
|
||||
a.EQPresetManager = NewEQPresetManager(confDir)
|
||||
|
||||
// Initialize AutoEQ manager
|
||||
autoEQTimeout := time.Duration(a.Config.Application.RequestTimeoutSeconds) * time.Second
|
||||
a.AutoEQManager = NewAutoEQManager(filepath.Join(cacheDir, "autoeq"), autoEQTimeout)
|
||||
|
||||
// Periodically scan for remote players
|
||||
go a.PlaybackManager.ScanRemotePlayers(a.bgrndCtx, true /*fastScan*/)
|
||||
@@ -399,11 +406,31 @@ func (a *App) setupMPV() error {
|
||||
a.LocalPlayer.SetAudioExclusive(a.Config.LocalPlayback.AudioExclusive)
|
||||
a.LocalPlayer.SetPauseFade(a.Config.LocalPlayback.PauseFade)
|
||||
|
||||
eq := &mpv.ISO15BandEqualizer{
|
||||
EQPreamp: a.Config.LocalPlayback.EqualizerPreamp,
|
||||
Disabled: !a.Config.LocalPlayback.EqualizerEnabled,
|
||||
// Initialize the appropriate equalizer type based on config
|
||||
var eq mpv.Equalizer
|
||||
if a.Config.LocalPlayback.EqualizerType == "ISO10Band" {
|
||||
eq10 := &mpv.ISO10BandEqualizer{
|
||||
EQPreamp: a.Config.LocalPlayback.EqualizerPreamp,
|
||||
Disabled: !a.Config.LocalPlayback.EqualizerEnabled,
|
||||
}
|
||||
// Copy up to 10 bands
|
||||
numBands := min(len(a.Config.LocalPlayback.GraphicEqualizerBands), 10)
|
||||
for i := 0; i < numBands; i++ {
|
||||
eq10.BandGains[i] = a.Config.LocalPlayback.GraphicEqualizerBands[i]
|
||||
}
|
||||
eq = eq10
|
||||
} else {
|
||||
eq15 := &mpv.ISO15BandEqualizer{
|
||||
EQPreamp: a.Config.LocalPlayback.EqualizerPreamp,
|
||||
Disabled: !a.Config.LocalPlayback.EqualizerEnabled,
|
||||
}
|
||||
// Copy up to 15 bands
|
||||
numBands := min(len(a.Config.LocalPlayback.GraphicEqualizerBands), 15)
|
||||
for i := 0; i < numBands; i++ {
|
||||
eq15.BandGains[i] = a.Config.LocalPlayback.GraphicEqualizerBands[i]
|
||||
}
|
||||
eq = eq15
|
||||
}
|
||||
copy(eq.BandGains[:], a.Config.LocalPlayback.GraphicEqualizerBands)
|
||||
a.LocalPlayer.SetEqualizer(eq)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
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
|
||||
}
|
||||
log.Printf("Successfully fetched %d AutoEQ profiles", len(profiles))
|
||||
|
||||
// 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
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
// InterpolateEQ10To15Band converts a 10-band EQ profile to Supersonic's 15-band ISO equalizer.
|
||||
// Uses logarithmic frequency positioning with linear dB interpolation.
|
||||
//
|
||||
// Parameters:
|
||||
// - eq10Gains: Array of 10 gain values (dB) from 10-band EQ 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 InterpolateEQ10To15Band(eq10Gains [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] = eq10Gains[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, eq10Gains)
|
||||
} 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] = eq10Gains[len(eq10Gains)-1]
|
||||
} else {
|
||||
// Interpolate between two AutoEQ bands
|
||||
fLow := autoEQFreqs[lowerIdx]
|
||||
fHigh := autoEQFreqs[upperIdx]
|
||||
gLow := eq10Gains[lowerIdx]
|
||||
gHigh := eq10Gains[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)
|
||||
}
|
||||
|
||||
// InterpolateEQ15BandTo10Band converts a 15-band ISO equalizer to 10-band format.
|
||||
// Uses logarithmic frequency positioning with linear dB interpolation.
|
||||
func InterpolateEQ15BandTo10Band(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 := InterpolateEQ10To15Band(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 := InterpolateEQ10To15Band(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 := InterpolateEQ10To15Band(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 := InterpolateEQ10To15Band(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 := InterpolateEQ10To15Band(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 := InterpolateEQ10To15Band(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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package mediaprovider
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
// Bit field flag for the ReleaseTypes property
|
||||
type ReleaseType = int32
|
||||
@@ -282,6 +286,7 @@ const (
|
||||
ContentTypeTrack
|
||||
ContentTypeGenre
|
||||
ContentTypeRadioStation
|
||||
ContentTypeOther
|
||||
)
|
||||
|
||||
func (c ContentType) String() string {
|
||||
@@ -298,6 +303,8 @@ func (c ContentType) String() string {
|
||||
return "Genre"
|
||||
case ContentTypeRadioStation:
|
||||
return "Radio station"
|
||||
case ContentTypeOther:
|
||||
return "Other"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -309,6 +316,9 @@ type SearchResult struct {
|
||||
CoverID string
|
||||
Type ContentType
|
||||
|
||||
// Optional icon to display instead of the default for this content type
|
||||
Icon fyne.Resource
|
||||
|
||||
// for Album / Playlist: track count
|
||||
// Artist / Genre: album count
|
||||
// Track: length (seconds)
|
||||
|
||||
@@ -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.25)
|
||||
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