Files
SoulStream/src-tauri/src/config.rs
T
KeelrLeek 793f78370e feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
2026-07-23 16:05:14 -05:00

320 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! App config. Non-secret settings live in `config.json`; the Soulseek
//! password lives in the Windows Credential Manager (via `keyring`).
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::Manager;
const KEYRING_SERVICE: &str = "com.soulstream.app";
/// Pre-rename service name; stored passwords migrate on first read.
const LEGACY_KEYRING_SERVICE: &str = "com.trifecta.soulseek-plus";
/// Pre-rename app-data folder; migrated by `migrate_app_data` at startup.
const LEGACY_APP_DATA_DIR: &str = "com.trifecta.soulseek-plus";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtistHeroUpscaler {
/// Fixed 3840x2160 GPU master, mip-filtered to the visible window.
#[default]
#[serde(alias = "fsr")]
LiveGpu,
#[serde(rename = "fsr_legacy")]
Fsr,
Lanczos3,
}
/// Ceiling for peer-source formats. `LossyOnly` skips lossless files —
/// the "don't stream multi-hundred-MB FLACs on this connection" cap.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceQualityCap {
#[default]
Any,
LossyOnly,
}
/// Floor for peer-source quality. Lossless formats satisfy every floor; a
/// lossy file must carry a known bitrate at or above the named floor.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceQualityFloor {
#[default]
Any,
Kbps192,
Kbps256,
Kbps320,
Lossless,
}
/// What the resolver optimizes among sources that satisfy the cap/floor:
/// best format (current behavior) or fastest transfer (free slot, upload
/// speed, short queue — format only breaks ties).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourcePriority {
#[default]
Quality,
Speed,
}
/// Per-mode source-selection preferences; streaming and downloading each
/// carry their own copy.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourcePrefs {
#[serde(default)]
pub max: SourceQualityCap,
#[serde(default)]
pub min: SourceQualityFloor,
#[serde(default)]
pub priority: SourcePriority,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default)]
pub username: String,
#[serde(default)]
pub listen_port: Option<u16>,
/// Where downloads are saved. None → default `<Music>\SoulSeek+`.
#[serde(default)]
pub download_dir: Option<String>,
/// Album-folder pattern below the managed library/download root.
/// Available placeholders: {artist}, {album}, {date}, {year}.
/// `\` or `/` in the pattern create subfolders (separators coming from
/// tag values are flattened to `_`).
#[serde(default)]
pub library_layout: Option<String>,
/// Extra folders to share (the downloads folder is always shared).
#[serde(default)]
pub shared_folders: Vec<String>,
/// Concurrent upload slots offered to peers.
#[serde(default)]
pub upload_slots: Option<u32>,
/// Named platform output endpoint. None follows Windows' default device.
#[serde(default)]
pub audio_output_device: Option<String>,
/// Spatial upscaler used for provider artist heroes below native 4K.
#[serde(default)]
pub artist_hero_upscaler: ArtistHeroUpscaler,
/// Source-selection preferences for streaming.
#[serde(default)]
pub stream_source_prefs: SourcePrefs,
/// Source-selection preferences for downloads/saves.
#[serde(default)]
pub download_source_prefs: SourcePrefs,
/// Replace whatever picture a download arrives with by the canonical
/// high-resolution cover. None → true. Startup-scanned library files are
/// never rewritten by this; it applies to fresh downloads only.
#[serde(default)]
pub replace_embedded_art: Option<bool>,
/// Longest edge for covers embedded into audio files. None → 1500.
/// Some(0) → embed the original untouched. The full-resolution original
/// is always kept in the art cache and the album folder regardless.
#[serde(default)]
pub embed_art_max_edge: Option<u32>,
/// Write the canonical cover as cover.jpg + folder.jpg in the organized
/// album folder so Explorer and other players show the same artwork.
/// None → true.
#[serde(default)]
pub write_folder_cover: Option<bool>,
/// Save every fully streamed track into the library. None → false:
/// streams stay only in the bounded stream cache below.
#[serde(default)]
pub stream_keep_forever: Option<bool>,
/// Days a streamed track stays in the stream cache after it was last
/// played. None → 30. Ignored when `stream_keep_forever` is on.
#[serde(default)]
pub stream_retention_days: Option<u32>,
/// Stream cache size cap in MB, oldest evicted first. None → 2048.
/// Some(0) → unlimited.
#[serde(default)]
pub stream_cache_max_mb: Option<u64>,
/// Loudness normalization (−14 LUFS target, BS.1770 measurement):
/// every track plays at roughly the same perceived level. None → true.
#[serde(default)]
pub volume_normalization: Option<bool>,
}
/// Default longest edge for embedded covers when the user hasn't chosen.
pub const DEFAULT_EMBED_ART_MAX_EDGE: u32 = 1500;
pub fn replace_embedded_art(cfg: &AppConfig) -> bool {
cfg.replace_embedded_art.unwrap_or(true)
}
/// 0 means "embed the original at full resolution".
pub fn embed_art_max_edge(cfg: &AppConfig) -> u32 {
cfg.embed_art_max_edge.unwrap_or(DEFAULT_EMBED_ART_MAX_EDGE)
}
pub fn write_folder_cover(cfg: &AppConfig) -> bool {
cfg.write_folder_cover.unwrap_or(true)
}
pub const DEFAULT_STREAM_RETENTION_DAYS: u32 = 30;
pub const DEFAULT_STREAM_CACHE_MAX_MB: u64 = 2048;
pub fn stream_keep_forever(cfg: &AppConfig) -> bool {
cfg.stream_keep_forever.unwrap_or(false)
}
pub fn stream_retention_days(cfg: &AppConfig) -> u32 {
cfg.stream_retention_days
.unwrap_or(DEFAULT_STREAM_RETENTION_DAYS)
.max(1)
}
/// 0 = unlimited.
pub fn stream_cache_max_mb(cfg: &AppConfig) -> u64 {
cfg.stream_cache_max_mb
.unwrap_or(DEFAULT_STREAM_CACHE_MAX_MB)
}
pub fn volume_normalization(cfg: &AppConfig) -> bool {
cfg.volume_normalization.unwrap_or(true)
}
/// One-time migration after the identifier rename: the app-data dir moved
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
/// the old dir into place so the library DB, config and stream cache
/// survive. Never overwrites existing data at the new location.
pub fn migrate_app_data(app: &tauri::AppHandle) {
let Ok(new_dir) = app.path().app_data_dir() else {
return;
};
let Some(parent) = new_dir.parent() else {
return;
};
let old_dir = parent.join(LEGACY_APP_DATA_DIR);
if !old_dir.is_dir() {
return;
}
let occupied = new_dir.exists()
&& std::fs::read_dir(&new_dir)
.map(|mut d| d.next().is_some())
.unwrap_or(true);
if occupied {
return;
}
if new_dir.exists() {
let _ = std::fs::remove_dir(&new_dir);
}
match std::fs::rename(&old_dir, &new_dir) {
Ok(()) => log::info!("migrated app data from {}", old_dir.display()),
Err(e) => log::warn!(
"app data migration failed ({e}); old data stays in {}",
old_dir.display()
),
}
}
fn config_path(app: &tauri::AppHandle) -> Option<PathBuf> {
let dir = app.path().app_data_dir().ok()?;
std::fs::create_dir_all(&dir).ok()?;
Some(dir.join("config.json"))
}
pub fn load(app: &tauri::AppHandle) -> AppConfig {
config_path(app)
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(app: &tauri::AppHandle, cfg: &AppConfig) {
if let Some(p) = config_path(app) {
if let Ok(json) = serde_json::to_string_pretty(cfg) {
if let Err(e) = std::fs::write(&p, json) {
log::warn!("failed to save config to {}: {e}", p.display());
}
}
}
}
/// The effective downloads directory: configured, or `<Music>\SoulStream`.
/// A pre-rename `<Music>\SoulSeek+` folder keeps being used if it exists,
/// so an existing library doesn't fork into a new empty root.
pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf {
if let Some(d) = &cfg.download_dir {
if !d.is_empty() {
return PathBuf::from(d);
}
}
let music = app
.path()
.audio_dir()
.or_else(|_| app.path().home_dir())
.unwrap_or_else(|_| PathBuf::from("."));
let legacy = music.join("SoulSeek+");
if legacy.is_dir() {
return legacy;
}
music.join("SoulStream")
}
pub const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}";
pub fn library_layout(cfg: &AppConfig) -> &str {
cfg.library_layout
.as_deref()
.map(str::trim)
.filter(|layout| !layout.is_empty())
.unwrap_or(DEFAULT_LIBRARY_LAYOUT)
}
// ---- password storage (Windows Credential Manager) ----
fn entry(username: &str) -> Option<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, username).ok()
}
pub fn save_password(username: &str, password: &str) {
if let Some(e) = entry(username) {
if let Err(err) = e.set_password(password) {
log::warn!("failed to store password in credential manager: {err}");
}
}
}
pub fn load_password(username: &str) -> Option<String> {
if let Some(pw) = entry(username).and_then(|e| e.get_password().ok()) {
return Some(pw);
}
// Fall back to the pre-rename service entry and migrate it over.
let legacy = keyring::Entry::new(LEGACY_KEYRING_SERVICE, username).ok()?;
let pw = legacy.get_password().ok()?;
save_password(username, &pw);
let _ = legacy.delete_credential();
Some(pw)
}
pub fn has_password(username: &str) -> bool {
!username.is_empty() && load_password(username).is_some()
}
pub fn delete_password(username: &str) {
if let Some(e) = entry(username) {
let _ = e.delete_credential();
}
}
#[cfg(test)]
mod tests {
use super::ArtistHeroUpscaler;
#[test]
fn legacy_fsr_setting_migrates_to_live_gpu() {
let migrated: ArtistHeroUpscaler = serde_json::from_str("\"fsr\"").unwrap();
assert_eq!(migrated, ArtistHeroUpscaler::LiveGpu);
assert_eq!(ArtistHeroUpscaler::default(), ArtistHeroUpscaler::LiveGpu);
assert_eq!(
serde_json::to_string(&ArtistHeroUpscaler::LiveGpu).unwrap(),
"\"live_gpu\""
);
assert_eq!(
serde_json::to_string(&ArtistHeroUpscaler::Fsr).unwrap(),
"\"fsr_legacy\""
);
}
}