// Package config handles loading user configuration from ~/.config/cliamp/config.toml. package config import ( "bufio" "errors" "fmt" "io/fs" "os" "path/filepath" "strconv" "strings" "time" "github.com/bjarneo/cliamp/internal/appdir" "github.com/bjarneo/cliamp/internal/fileutil" ) // configPath returns the path to the config file. func configPath() (string, error) { dir, err := appdir.Dir() if err != nil { return "", err } return filepath.Join(dir, "config.toml"), nil } // parseString trims surrounding quotes from a TOML string value and, if the // result is exactly $NAME or ${NAME}, replaces it with the value of that // environment variable (or "" when unset). Mixed values containing other // characters are left untouched, so literal '$' in passwords is preserved. func parseString(s string) string { s = strings.Trim(s, `"'`) if len(s) < 2 || s[0] != '$' { return s } name := s[1:] if name[0] == '{' { if name[len(name)-1] != '}' { return s } name = name[1 : len(name)-1] } if !isEnvName(name) { return s } return os.Getenv(name) } func isEnvName(s string) bool { if s == "" { return false } for i, r := range s { switch { case r == '_': case r >= 'A' && r <= 'Z': case r >= 'a' && r <= 'z': case i > 0 && r >= '0' && r <= '9': default: return false } } return true } // NavidromeConfig holds credentials for a Navidrome/Subsonic server. // All three fields must be non-empty for a client to be constructed. type NavidromeConfig struct { URL string // e.g. "https://music.example.com" User string Password string BrowseSort string // album browse sort order, e.g. "alphabeticalByName" ScrobbleDisabled bool // true only when "scrobble = false" is explicitly set } // IsSet reports whether all three Navidrome credentials are present. func (n NavidromeConfig) IsSet() bool { return n.URL != "" && n.User != "" && n.Password != "" } // SpotifyConfig holds settings for the Spotify provider. Requires a Spotify // Premium account. If client_id is empty, a built-in fallback (the librespot // keymaster ID) is used so search and catalog endpoints work even for users // who never registered their own developer app — see Spotify's Nov 27, 2024 // dev-mode quota restriction. type SpotifyConfig struct { Disabled bool // true only when user explicitly sets enabled = false Enabled bool // true when [spotify] section exists (even without client_id) ClientID string // Spotify Developer app client ID (overrides built-in fallback) Bitrate int // preferred Spotify stream bitrate in kbps } // IsSet reports whether the Spotify provider should be shown. Section presence // is enough — a built-in fallback client_id is used when none is configured. func (s SpotifyConfig) IsSet() bool { return !s.Disabled && s.Enabled } // ResolveClientID returns the user's configured client_id, or fallbackID when // none is set. func (s SpotifyConfig) ResolveClientID(fallbackID string) string { if s.ClientID != "" { return s.ClientID } return fallbackID } // QobuzConfig holds settings for the Qobuz provider. Requires a paid Qobuz // subscription (Studio/Sublime). The app_id, signing secrets and OAuth private // key are scraped automatically from the Qobuz web player, so no developer // credentials are needed. Sign-in is an interactive OAuth browser flow. type QobuzConfig struct { Disabled bool // true only when user explicitly sets enabled = false Enabled bool // true when [qobuz] section exists Quality int // preferred stream format_id: 5 (MP3 320), 6 (FLAC CD), 7 (Hi-Res <=96kHz), 27 (Hi-Res <=192kHz) } // IsSet reports whether the Qobuz provider should be shown. Section presence // is enough; credentials are scraped from the Qobuz web player automatically. func (q QobuzConfig) IsSet() bool { return !q.Disabled && q.Enabled } // YouTubeMusicConfig holds settings for the YouTube Music provider. // If no client_id/client_secret are set, built-in fallback credentials are // used automatically (same pattern as Spotify). type YouTubeMusicConfig struct { Disabled bool // true only when user explicitly sets enabled = false Enabled bool // true when [ytmusic] section exists (even without credentials) ClientID string // Google Cloud OAuth2 client ID (overrides built-in fallback) ClientSecret string // Google Cloud OAuth2 client secret (overrides built-in fallback) CookiesFrom string // browser name for yt-dlp --cookies-from-browser (e.g. "chrome", "firefox") } // IsSetOrFallback returns true when YouTube providers should be enabled, // either via config or because fallback credentials are available. func (y YouTubeMusicConfig) IsSetOrFallback(fallbackFn func() (string, string)) bool { if y.Disabled { return false } if y.Enabled { return true } // Even without a config section, enable if fallback credentials exist. if fallbackFn != nil { id, secret := fallbackFn() return id != "" && secret != "" } return false } // ResolveCredentials returns the user's configured credentials, or falls back // to the built-in pool. Returns empty strings only when the pool is also empty. func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string)) (clientID, clientSecret string) { if y.ClientID != "" && y.ClientSecret != "" { return y.ClientID, y.ClientSecret } if fallbackFn != nil { return fallbackFn() } return "", "" } // SoundCloudConfig holds settings for the SoundCloud provider. // SoundCloud is opt-in: requires enabled = true in [soundcloud] before the // provider registers. Setting User exposes that profile's Tracks/Likes/Reposts // in the browse view. Setting CookiesFrom (browser name) lets yt-dlp use the // user's signed-in session for subscriber-gated tracks. type SoundCloudConfig struct { Enabled bool // true only when user explicitly sets enabled = true User string // SoundCloud username for browse (optional) CookiesFrom string // browser name for yt-dlp --cookies-from-browser (optional) } // IsSet reports whether the SoundCloud provider should be shown. func (s SoundCloudConfig) IsSet() bool { return s.Enabled } // NetEaseConfig holds settings for the NetEase Cloud Music provider. // The provider is opt-in and can reuse an existing browser session through // yt-dlp's --cookies-from-browser support. type NetEaseConfig struct { Enabled bool // true only when user explicitly sets enabled = true CookiesFrom string // browser name for account APIs and playback (e.g. "chrome") UserID string // optional account user id; setup can discover this from cookies } // IsSet reports whether the NetEase provider should be shown. func (n NetEaseConfig) IsSet() bool { return n.Enabled } // PlexConfig holds credentials for a Plex Media Server. // Both URL and Token must be non-empty for a client to be constructed. type PlexConfig struct { URL string // e.g. "http://192.168.1.10:32400" Token string // X-Plex-Token Libraries []string // optional: restrict to these music library names } // IsSet reports whether both Plex credentials are present. func (p PlexConfig) IsSet() bool { return p.URL != "" && p.Token != "" } // JellyfinConfig holds credentials for a Jellyfin server. // URL is required. Authenticate either with Token, or with User+Password. // UserID is optional and can be discovered lazily. type JellyfinConfig struct { URL string // e.g. "https://jellyfin.example.com" Token string // API access token User string // optional username for password-based login Password string // optional password for password-based login UserID string // optional user id to skip discovery via /Users/Me } // IsSet reports whether the Jellyfin provider is configured. func (j JellyfinConfig) IsSet() bool { return j.URL != "" && (j.Token != "" || (j.User != "" && j.Password != "")) } // EmbyConfig holds credentials for an Emby server. // URL is required. Authenticate either with Token, or with User+Password. // UserID is optional and can be discovered lazily. type EmbyConfig struct { URL string // e.g. "https://emby.example.com" Token string // API access token User string // optional username for password-based login Password string // optional password for password-based login UserID string // optional user id to skip discovery via /Users/Me } // IsSet reports whether the Emby provider is configured. func (e EmbyConfig) IsSet() bool { return e.URL != "" && (e.Token != "" || (e.User != "" && e.Password != "")) } // Config holds user preferences loaded from the config file. type Config struct { Volume float64 // dB, clamped at runtime to [VolumeMin, +6] VolumeMin float64 // dB floor, range [-90, 0]; default -50 VisVolumeLinked bool // when true, visualizer bar height follows volume; default true EQ [10]float64 // per-band gain in dB, range [-12, +12] EQPreset string // preset name, or "" for custom Repeat string // "off", "all", or "one" Shuffle bool Mono bool Speed float64 // playback speed ratio: 0.25–2.0 (default 1.0) AutoPlay bool // start playback automatically on launch (radio streams, CLI tracks) SeekStepLarge int // seconds for Shift+Left/Right seek jumps Provider string // default provider: "radio", "navidrome", "spotify", "qobuz", "plex", "jellyfin", "emby", "soundcloud", "netease", "ytmusic" (default "radio") Theme string // theme name, or "" for ANSI default Visualizer string // visualizer mode name, or "" for default (Bars) SampleRate int // output sample rate: 22050, 44100, 48000, 96000, 192000 BufferMs int // speaker buffer in milliseconds (50–500) ResampleQuality int // beep resample quality factor (1–4) BitDepth int // PCM bit depth for FFmpeg output: 16 or 32 Compact bool // compact mode: cap frame width at 80 columns PaddingH int // horizontal padding for the UI frame (default 3) PaddingV int // vertical padding for the UI frame (default 1) AudioDevice string // preferred audio output device name (empty = system default) Playlist string // local TOML playlist name to load on startup InitialDirectory string // initial directory for the file browser Navidrome NavidromeConfig // optional Navidrome/Subsonic server credentials Spotify SpotifyConfig // optional Spotify provider (requires Premium) Qobuz QobuzConfig // optional Qobuz provider (requires subscription) YouTubeMusic YouTubeMusicConfig // optional YouTube Music provider Plex PlexConfig // optional Plex Media Server credentials Jellyfin JellyfinConfig // optional Jellyfin server credentials Emby EmbyConfig // optional Emby server credentials SoundCloud SoundCloudConfig // SoundCloud provider (opt-in via enabled = true) NetEase NetEaseConfig // NetEase Cloud Music provider (opt-in via enabled = true) Plugins map[string]map[string]string // per-plugin config from [plugins.*] sections LogLevel string // log level: debug, info, warn, error (default "info") LowPower bool // reduce CPU by lowering UI cadence and disabling visualization } // defaultConfig returns a Config with sensible defaults. // SampleRate defaults to 0, which means "auto-detect from the system's default // output device" (see player.DeviceSampleRate). This ensures USB audio devices // that require a specific rate (commonly 48 kHz) work out of the box. func defaultConfig() Config { return Config{ VolumeMin: -50, VisVolumeLinked: true, Repeat: "off", AutoPlay: false, Speed: 1.0, SeekStepLarge: 30, SampleRate: 0, BufferMs: 100, ResampleQuality: 4, BitDepth: 16, PaddingH: 3, PaddingV: 1, Spotify: SpotifyConfig{Bitrate: 320}, Qobuz: QobuzConfig{Quality: 6}, LogLevel: "info", } } // Load reads the config file from ~/.config/cliamp/config.toml. // Returns defaults if the file does not exist. func Load() (Config, error) { cfg := defaultConfig() path, err := configPath() if err != nil { return cfg, nil } f, err := os.Open(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return cfg, nil } return cfg, err } defer f.Close() scanner := bufio.NewScanner(f) section := "" // current [section] header, empty = top-level for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } // Section header: [navidrome], [plex], [plugins.lastfm], etc. if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { section = strings.ToLower(line[1 : len(line)-1]) // Mark providers as enabled when their section exists. // [yt], [youtube], and [ytmusic] all configure the same YouTube providers. switch section { case "yt", "youtube", "ytmusic": cfg.YouTubeMusic.Enabled = true section = "ytmusic" // normalize for key parsing below case "spotify": cfg.Spotify.Enabled = true case "qobuz": cfg.Qobuz.Enabled = true } // Initialize plugin sub-maps for [plugins] and [plugins.*] sections. if section == "plugins" || strings.HasPrefix(section, "plugins.") { if cfg.Plugins == nil { cfg.Plugins = make(map[string]map[string]string) } pluginName := strings.TrimPrefix(section, "plugins.") if pluginName == "plugins" { pluginName = "" // top-level [plugins] section } if _, ok := cfg.Plugins[pluginName]; !ok { cfg.Plugins[pluginName] = make(map[string]string) } } continue } key, val, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) val = strings.TrimSpace(val) switch section { case "navidrome": switch key { case "url": cfg.Navidrome.URL = parseString(val) case "user": cfg.Navidrome.User = parseString(val) case "password": cfg.Navidrome.Password = parseString(val) case "browse_sort": cfg.Navidrome.BrowseSort = parseString(val) case "scrobble": // Opt-out: only mark disabled when the value is explicitly "false". cfg.Navidrome.ScrobbleDisabled = strings.ToLower(val) == "false" } case "spotify": switch key { case "enabled": cfg.Spotify.Disabled = strings.ToLower(val) == "false" case "client_id": cfg.Spotify.ClientID = parseString(val) case "bitrate": if v, err := strconv.Atoi(val); err == nil { cfg.Spotify.Bitrate = v } } case "qobuz": switch key { case "enabled": cfg.Qobuz.Disabled = strings.ToLower(val) == "false" case "quality": if v, err := strconv.Atoi(val); err == nil { cfg.Qobuz.Quality = v } } case "ytmusic": switch key { case "enabled": cfg.YouTubeMusic.Disabled = strings.ToLower(val) == "false" case "client_id": cfg.YouTubeMusic.ClientID = parseString(val) case "client_secret": cfg.YouTubeMusic.ClientSecret = parseString(val) case "cookies_from": cfg.YouTubeMusic.CookiesFrom = parseString(val) } case "plex": switch key { case "url": cfg.Plex.URL = parseString(val) case "token": cfg.Plex.Token = parseString(val) case "libraries": cfg.Plex.Libraries = parseStringSlice(val) } case "soundcloud": switch key { case "enabled": cfg.SoundCloud.Enabled = strings.ToLower(val) == "true" case "user": cfg.SoundCloud.User = parseString(val) case "cookies_from": cfg.SoundCloud.CookiesFrom = parseString(val) } case "netease": switch key { case "enabled": cfg.NetEase.Enabled = strings.ToLower(val) == "true" case "cookies_from": cfg.NetEase.CookiesFrom = parseString(val) case "user_id": cfg.NetEase.UserID = parseString(val) } case "jellyfin": switch key { case "url": cfg.Jellyfin.URL = parseString(val) case "token": cfg.Jellyfin.Token = parseString(val) case "user": cfg.Jellyfin.User = parseString(val) case "password": cfg.Jellyfin.Password = parseString(val) case "user_id": cfg.Jellyfin.UserID = parseString(val) } case "emby": switch key { case "url": cfg.Emby.URL = parseString(val) case "token": cfg.Emby.Token = parseString(val) case "user": cfg.Emby.User = parseString(val) case "password": cfg.Emby.Password = parseString(val) case "user_id": cfg.Emby.UserID = parseString(val) } default: // Handle [plugins] and [plugins.*] sections. if section == "plugins" || strings.HasPrefix(section, "plugins.") { pluginName := strings.TrimPrefix(section, "plugins.") if pluginName == "plugins" { pluginName = "" // top-level [plugins] section } if cfg.Plugins != nil { if m, ok := cfg.Plugins[pluginName]; ok { m[key] = parseString(val) } } continue } switch key { case "volume": if v, err := strconv.ParseFloat(val, 64); err == nil { cfg.Volume = v } case "volume_min": if v, err := strconv.ParseFloat(val, 64); err == nil { cfg.VolumeMin = v } case "vis_volume_linked": if v, err := strconv.ParseBool(val); err == nil { cfg.VisVolumeLinked = v } case "repeat": val = parseString(val) switch strings.ToLower(val) { case "all", "one", "off": cfg.Repeat = strings.ToLower(val) } case "shuffle": cfg.Shuffle = val == "true" case "mono": cfg.Mono = val == "true" case "auto_play": cfg.AutoPlay = val == "true" case "seek_large_step_sec": if v, err := strconv.Atoi(val); err == nil { cfg.SeekStepLarge = v } case "eq": cfg.EQ = parseEQ(val) case "eq_preset": cfg.EQPreset = parseString(val) case "theme": cfg.Theme = parseString(val) case "provider": cfg.Provider = strings.ToLower(parseString(val)) case "visualizer": cfg.Visualizer = parseString(val) case "sample_rate": if v, err := strconv.Atoi(val); err == nil { cfg.SampleRate = v } case "buffer_ms": if v, err := strconv.Atoi(val); err == nil { cfg.BufferMs = v } case "resample_quality": if v, err := strconv.Atoi(val); err == nil { cfg.ResampleQuality = v } case "bit_depth": if v, err := strconv.Atoi(val); err == nil { cfg.BitDepth = v } case "speed": if v, err := strconv.ParseFloat(val, 64); err == nil { cfg.Speed = v } case "compact": cfg.Compact = val == "true" case "audio_device": cfg.AudioDevice = parseString(val) case "initial_directory": cfg.InitialDirectory = parseString(val) case "padding_horizontal": if v, err := strconv.Atoi(val); err == nil { cfg.PaddingH = v } case "padding_vertical": if v, err := strconv.Atoi(val); err == nil { cfg.PaddingV = v } case "log_level": lvl := strings.ToLower(parseString(val)) switch lvl { case "debug", "info", "warn", "warning", "error": cfg.LogLevel = lvl } case "low_power": cfg.LowPower = strings.ToLower(val) == "true" } } } cfg.clamp() return cfg, scanner.Err() } // Save updates only the given key in the existing config file, preserving // all other content, comments, and formatting. If the key doesn't exist, // it is appended. If no config file exists, one is created with just that key. func Save(key, value string) error { path, err := configPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } line := fmt.Sprintf("%s = %s", key, value) data, err := os.ReadFile(path) if err != nil { if !errors.Is(err, fs.ErrNotExist) { return err } return fileutil.WriteFileAtomic(path, []byte(line+"\n"), 0o600) } // Scan existing lines and replace the matching key in-place, // but only in the top-level scope (before any [section] header). lines := strings.Split(string(data), "\n") found := false for i, l := range lines { trimmed := strings.TrimSpace(l) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } // Stop searching once we hit a section header — the key // belongs in the top-level scope only. if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { break } k, _, ok := strings.Cut(trimmed, "=") if ok && strings.TrimSpace(k) == key { lines[i] = line found = true break } } if !found { // Insert before the first section header to keep top-level keys together. inserted := false for i, l := range lines { trimmed := strings.TrimSpace(l) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { lines = append(lines[:i], append([]string{line}, lines[i:]...)...) inserted = true break } } if !inserted { lines = append(lines, line) } } return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600) } // SaveNavidromeSort persists the given album browse sort type to the // [navidrome] section of the config file. It rewrites the browse_sort key // in-place, or appends it after the [navidrome] section if not present. // If no [navidrome] section exists, one is appended along with the key. func SaveNavidromeSort(sortType string) error { path, err := configPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } line := fmt.Sprintf("browse_sort = %q", sortType) data, err := os.ReadFile(path) if err != nil { if !errors.Is(err, fs.ErrNotExist) { return err } // No file: create with section + key. return fileutil.WriteFileAtomic(path, []byte("[navidrome]\n"+line+"\n"), 0o600) } lines := strings.Split(string(data), "\n") // Try to replace an existing browse_sort inside [navidrome]. inNavidrome := false for i, l := range lines { trimmed := strings.TrimSpace(l) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { inNavidrome = strings.ToLower(trimmed[1:len(trimmed)-1]) == "navidrome" continue } if inNavidrome { k, _, ok := strings.Cut(trimmed, "=") if ok && strings.TrimSpace(k) == "browse_sort" { lines[i] = line return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600) } } } // Key not found: append after the last line in the [navidrome] section, // or append a new [navidrome] section at the end. inNavidrome = false insertAt := -1 for i, l := range lines { trimmed := strings.TrimSpace(l) if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { if inNavidrome && insertAt >= 0 { break // we've moved past [navidrome] } inNavidrome = strings.ToLower(trimmed[1:len(trimmed)-1]) == "navidrome" } if inNavidrome { insertAt = i } } if insertAt >= 0 { // Insert after the last line we saw inside [navidrome]. tail := append([]string{line}, lines[insertAt+1:]...) lines = append(lines[:insertAt+1], tail...) } else { // No [navidrome] section found: append one. lines = append(lines, "[navidrome]", line) } return fileutil.WriteFileAtomic(path, []byte(strings.Join(lines, "\n")), 0o600) } // PlayerConfig is the subset of player controls needed to apply config. type PlayerConfig interface { SetVolumeMin(db float64) SetVolume(db float64) SetSpeed(ratio float64) SetEQBand(band int, dB float64) ToggleMono() } // PlaylistConfig is the subset of playlist controls needed to apply config. type PlaylistConfig interface { CycleRepeat() ToggleShuffle() } // ApplyPlayer applies audio-engine settings from the config. func (c Config) ApplyPlayer(p PlayerConfig) { p.SetVolumeMin(c.VolumeMin) p.SetVolume(c.Volume) if c.Speed != 0 && c.Speed != 1.0 { p.SetSpeed(c.Speed) } if c.EQPreset == "" || c.EQPreset == "Custom" { for i, gain := range c.EQ { p.SetEQBand(i, gain) } } if c.Mono { p.ToggleMono() } } // ApplyPlaylist applies playlist-state settings from the config. func (c Config) ApplyPlaylist(pl PlaylistConfig) { switch c.Repeat { case "all": pl.CycleRepeat() // off -> all case "one": pl.CycleRepeat() // off -> all pl.CycleRepeat() // all -> one } if c.Shuffle { pl.ToggleShuffle() } } // SeekStepLargeDuration returns the configured Shift+Left/Right seek jump. func (c Config) SeekStepLargeDuration() time.Duration { return time.Duration(c.SeekStepLarge) * time.Second } // clamp constrains all Config fields to their valid ranges. func (c *Config) clamp() { c.VolumeMin = max(min(c.VolumeMin, 0), -90) c.Volume = max(min(c.Volume, 6), c.VolumeMin) if c.Speed < 0.25 || c.Speed > 2.0 { c.Speed = 1.0 } c.SeekStepLarge = max(min(c.SeekStepLarge, 600), 6) c.SampleRate = clampSampleRate(c.SampleRate) c.BufferMs = max(min(c.BufferMs, 500), 50) c.ResampleQuality = max(min(c.ResampleQuality, 4), 1) c.BitDepth = clampBitDepth(c.BitDepth) c.Spotify.Bitrate = clampSpotifyBitrate(c.Spotify.Bitrate) c.PaddingH = max(min(c.PaddingH, 10), 0) c.PaddingV = max(min(c.PaddingV, 5), 0) if c.LowPower { c.Visualizer = "none" } } // nearestAllowed returns the value in allowed closest to v. // allowed must be non-empty. func nearestAllowed(v int, allowed []int) int { best := allowed[0] bestDist := abs(v - best) for _, a := range allowed[1:] { if d := abs(v - a); d < bestDist { best = a bestDist = d } } return best } // clampSampleRate returns the nearest valid sample rate from the allowed set. // A value of 0 is preserved as-is to signal "auto-detect" to the player. func clampSampleRate(v int) int { if v == 0 { return 0 // auto-detect } return nearestAllowed(v, []int{22050, 44100, 48000, 96000, 192000}) } // clampBitDepth returns the nearest valid bit depth (16 or 32). func clampBitDepth(v int) int { if v >= 24 { return 32 } return 16 } func clampSpotifyBitrate(v int) int { if v <= 0 { return 320 } return nearestAllowed(v, []int{96, 160, 320}) } func abs(x int) int { if x < 0 { return -x } return x } // parseStringSlice parses a comma-separated list of strings, optionally // wrapped in square brackets (e.g. `["Music", "Jazz"]` or `Music, Jazz`). // Leading/trailing whitespace and surrounding quotes are stripped from each element. func parseStringSlice(val string) []string { val = strings.Trim(val, "[]") parts := strings.Split(val, ",") result := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) p = strings.Trim(p, `"'`) if p != "" { result = append(result, p) } } return result } // parseEQ parses a TOML-style array like [0, 1.5, -2, ...] into 10 bands. func parseEQ(val string) [10]float64 { var bands [10]float64 val = strings.Trim(val, "[]") parts := strings.Split(val, ",") for i, p := range parts { if i >= 10 { break } if v, err := strconv.ParseFloat(strings.TrimSpace(p), 64); err == nil { bands[i] = max(min(v, 12), -12) } } return bands }