fix(audio): stabilize loudness normalization and streaming startup behavior

Improve normalization consistency by separating cache refresh for non-playing tracks, hardening track-id cache lookups, and keeping UI gain state aligned with actual playback. Also reduce startup stalls by preferring non-seekable streaming when format hints are missing and by tuning ALSA/analysis paths to avoid underrun-prone churn.
This commit is contained in:
Maxim Isaev
2026-04-25 22:09:38 +03:00
parent 93b724fc65
commit 86704473ed
14 changed files with 2092 additions and 37 deletions
+75
View File
@@ -944,6 +944,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dasp_frame"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
dependencies = [
"dasp_sample",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -1169,6 +1178,18 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ebur128"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9"
dependencies = [
"bitflags 1.3.2",
"dasp_frame",
"dasp_sample",
"smallvec",
]
[[package]]
name = "either"
version = "1.15.0"
@@ -1302,6 +1323,18 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "1.9.0"
@@ -1908,6 +1941,15 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -2548,6 +2590,17 @@ dependencies = [
"redox_syscall 0.7.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.3.8"
@@ -3657,6 +3710,7 @@ version = "1.43.0"
dependencies = [
"biquad",
"discord-rich-presence",
"ebur128",
"futures-util",
"id3",
"libc",
@@ -3665,6 +3719,7 @@ dependencies = [
"reqwest 0.12.28",
"ringbuf",
"rodio",
"rusqlite",
"serde",
"serde_json",
"souvlaki",
@@ -4116,6 +4171,20 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags 2.11.1",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
@@ -6115,6 +6184,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
+2
View File
@@ -49,6 +49,8 @@ lofty = "0.22"
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.7"
rusqlite = { version = "0.37", features = ["bundled"] }
ebur128 = "0.1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -1063,10 +1063,11 @@ fn set_hw_params_from_format(
hw_params.set_buffer_size(v as alsa::pcm::Frames)?;
}
BufferSize::Default => {
// These values together represent a moderate latency and wakeup interval.
// Without them, we are at the mercy of the device
hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?;
hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?;
// Larger buffer than upstream cpal default: after Symphonia seeks or CPU
// spikes (analysis, demux) the mixer needs more margin before ALSA underruns.
// ~40 ms period, ~200 ms total buffer (device may round to nearest supported).
hw_params.set_period_time_near(40_000, alsa::ValueOr::Nearest)?;
hw_params.set_buffer_time_near(200_000, alsa::ValueOr::Nearest)?;
}
}
+559
View File
@@ -0,0 +1,559 @@
use std::path::PathBuf;
use std::io::Cursor;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use ebur128::{EbuR128, Mode as Ebur128Mode};
use rusqlite::{params, Connection, OptionalExtension};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{CODEC_TYPE_NULL, DecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use tauri::Manager;
pub const WAVEFORM_ALGO_VERSION: i64 = 1;
pub const LOUDNESS_ALGO_VERSION: i64 = 1;
#[derive(Debug, Clone)]
pub struct TrackKey {
pub track_id: String,
pub md5_16kb: String,
}
#[derive(Debug, Clone)]
pub struct WaveformEntry {
pub bins: Vec<u8>,
pub bin_count: i64,
pub is_partial: bool,
pub known_until_sec: f64,
pub duration_sec: f64,
pub updated_at: i64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct LoudnessEntry {
pub integrated_lufs: f64,
pub true_peak: f64,
pub recommended_gain_db: f64,
pub target_lufs: f64,
pub updated_at: i64,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct LoudnessSnapshot {
pub integrated_lufs: f64,
pub true_peak: f64,
pub recommended_gain_db: f64,
pub target_lufs: f64,
pub updated_at: i64,
}
pub struct AnalysisCache {
conn: Mutex<Connection>,
}
/// Ranged HTTP seeding uses `stream:<subsonicId>` (see `playback_identity`); backfill
/// and IPC often use the bare `<subsonicId>`. Rows may exist under either key.
fn track_id_cache_variants(id: &str) -> Vec<String> {
let mut out = vec![id.to_string()];
if let Some(bare) = id.strip_prefix("stream:") {
if !bare.is_empty() {
out.push(bare.to_string());
}
} else {
out.push(format!("stream:{id}"));
}
out
}
impl AnalysisCache {
pub fn init(app: &tauri::AppHandle) -> Result<Self, String> {
let db_path = analysis_db_path(app)?;
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let conn = Connection::open(db_path).map_err(|e| e.to_string())?;
configure_connection(&conn).map_err(|e| e.to_string())?;
migrate_schema(&conn).map_err(|e| e.to_string())?;
Ok(Self { conn: Mutex::new(conn) })
}
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
let now = now_unix_ts();
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO analysis_track (
track_id, md5_16kb, status, waveform_algo_version, loudness_algo_version, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
status = excluded.status,
waveform_algo_version = excluded.waveform_algo_version,
loudness_algo_version = excluded.loudness_algo_version,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
status,
WAVEFORM_ALGO_VERSION,
LOUDNESS_ALGO_VERSION,
now
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_waveform(&self, key: &TrackKey, entry: &WaveformEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO waveform_cache (
track_id, md5_16kb, bins, bin_count, is_partial, known_until_sec, duration_sec, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(track_id, md5_16kb) DO UPDATE SET
bins = excluded.bins,
bin_count = excluded.bin_count,
is_partial = excluded.is_partial,
known_until_sec = excluded.known_until_sec,
duration_sec = excluded.duration_sec,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
entry.bins,
entry.bin_count,
if entry.is_partial { 1 } else { 0 },
entry.known_until_sec,
entry.duration_sec,
entry.updated_at
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn upsert_loudness(&self, key: &TrackKey, entry: &LoudnessEntry) -> Result<(), String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.execute(
r#"
INSERT INTO loudness_cache (
track_id, md5_16kb, integrated_lufs, true_peak, recommended_gain_db, target_lufs, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(track_id, md5_16kb, target_lufs) DO UPDATE SET
integrated_lufs = excluded.integrated_lufs,
true_peak = excluded.true_peak,
recommended_gain_db = excluded.recommended_gain_db,
updated_at = excluded.updated_at
"#,
params![
key.track_id,
key.md5_16kb,
entry.integrated_lufs,
entry.true_peak,
entry.recommended_gain_db,
entry.target_lufs,
entry.updated_at
],
)
.map_err(|e| e.to_string())?;
Ok(())
}
pub fn get_waveform(&self, key: &TrackKey) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
conn.query_row(
r#"
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
FROM waveform_cache w
JOIN analysis_track a
ON a.track_id = w.track_id
AND a.md5_16kb = w.md5_16kb
WHERE w.track_id = ?1
AND w.md5_16kb = ?2
AND a.waveform_algo_version = ?3
"#,
params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION],
|row| {
Ok(WaveformEntry {
bins: row.get(0)?,
bin_count: row.get(1)?,
is_partial: row.get::<_, i64>(2)? != 0,
known_until_sec: row.get(3)?,
duration_sec: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(|e| e.to_string())
}
pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result<Option<WaveformEntry>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at
FROM waveform_cache w
JOIN analysis_track a
ON a.track_id = w.track_id
AND a.md5_16kb = w.md5_16kb
WHERE w.track_id = ?1
AND a.waveform_algo_version = ?2
ORDER BY w.updated_at DESC
LIMIT 1
"#;
for tid in track_id_cache_variants(track_id) {
let row = conn
.query_row(
SQL,
params![tid, WAVEFORM_ALGO_VERSION],
|row| {
Ok(WaveformEntry {
bins: row.get(0)?,
bin_count: row.get(1)?,
is_partial: row.get::<_, i64>(2)? != 0,
known_until_sec: row.get(3)?,
duration_sec: row.get(4)?,
updated_at: row.get(5)?,
})
},
)
.optional()
.map_err(|e| e.to_string())?;
if row.is_some() {
return Ok(row);
}
}
Ok(None)
}
pub fn get_latest_loudness_for_track(&self, track_id: &str) -> Result<Option<LoudnessSnapshot>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
const SQL: &str = r#"
SELECT l.integrated_lufs, l.true_peak, l.recommended_gain_db, l.target_lufs, l.updated_at
FROM loudness_cache l
JOIN analysis_track a
ON a.track_id = l.track_id
AND a.md5_16kb = l.md5_16kb
WHERE l.track_id = ?1
AND a.loudness_algo_version = ?2
ORDER BY l.updated_at DESC
LIMIT 1
"#;
for tid in track_id_cache_variants(track_id) {
let row = conn
.query_row(
SQL,
params![tid, LOUDNESS_ALGO_VERSION],
|row| {
Ok(LoudnessSnapshot {
integrated_lufs: row.get(0)?,
true_peak: row.get(1)?,
recommended_gain_db: row.get(2)?,
target_lufs: row.get(3)?,
updated_at: row.get(4)?,
})
},
)
.optional()
.map_err(|e| e.to_string())?;
if row.is_some() {
return Ok(row);
}
}
Ok(None)
}
}
pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_lufs: f64) -> f64 {
let mut recommended_gain_db = target_lufs - integrated_lufs;
if true_peak > 0.0 {
let true_peak_dbtp = 20.0 * true_peak.log10();
let max_gain_db = -1.0 - true_peak_dbtp;
if recommended_gain_db > max_gain_db {
recommended_gain_db = max_gain_db;
}
}
recommended_gain_db.clamp(-24.0, 24.0)
}
pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<(), String> {
let Some(cache) = app.try_state::<AnalysisCache>() else {
return Ok(());
};
let key = TrackKey {
track_id: track_id.to_string(),
md5_16kb: md5_first_16kb(bytes),
};
cache.touch_track_status(&key, "queued")?;
let waveform = WaveformEntry {
bins: derive_waveform_bins(bytes, 500),
bin_count: 500,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 0.0,
updated_at: now_unix_ts(),
};
cache.upsert_waveform(&key, &waveform)?;
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) =
analyze_loudness_from_audio_bytes(bytes, -16.0)
{
let loudness = LoudnessEntry {
integrated_lufs,
true_peak,
recommended_gain_db,
target_lufs,
updated_at: now_unix_ts(),
};
cache.upsert_loudness(&key, &loudness)?;
}
cache.touch_track_status(&key, "ready")?;
Ok(())
}
fn now_unix_ts() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn md5_first_16kb(bytes: &[u8]) -> String {
let n = bytes.len().min(16 * 1024);
format!("{:x}", md5::compute(&bytes[..n]))
}
fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec<u8> {
if bin_count == 0 || bytes.is_empty() {
return Vec::new();
}
let mut out = vec![0u8; bin_count];
for (i, slot) in out.iter_mut().enumerate() {
let start = i * bytes.len() / bin_count;
let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len());
let mut peak: u8 = 0;
for &b in &bytes[start..end] {
let centered = if b >= 128 { b - 128 } else { 128 - b };
if centered > peak {
peak = centered;
}
}
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
}
out
}
fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(f64, f64, f64, f64)> {
if bytes.is_empty() {
return None;
}
let source = Box::new(Cursor::new(bytes.to_vec()));
let mss = MediaSourceStream::new(source, Default::default());
let hint = Hint::new();
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
.ok()?;
let mut format = probed.format;
let track = format
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format
.tracks()
.iter()
.find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
})
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
let track_id = track.id;
let codec_params = track.codec_params.clone();
let mut decoder = match symphonia::default::get_codecs()
.make(&codec_params, &DecoderOptions::default())
{
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
return None;
}
};
let mut ebu: Option<EbuR128> = None;
let mut ebu_channels: u32 = 0;
let mut sample_peak_abs = 0.0_f64;
let mut fed_any_frames = false;
let mut loop_i: u32 = 0;
loop {
let packet = match format.next_packet() {
Ok(packet) => packet,
Err(_) => break,
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(buf) => buf,
Err(SymphoniaError::DecodeError(_)) => continue,
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let spec = *decoded.spec();
if ebu.is_none() {
let ch = spec.channels.count() as u32;
let sr = spec.rate;
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
Ok(v) => {
ebu = Some(v);
ebu_channels = ch;
}
Err(e) => {
crate::app_deprintln!(
"[analysis] EbuR128 init failed: channels={} sample_rate={} err={}",
ch,
sr,
e
);
return None;
}
}
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
for &s in samples.samples() {
let v = (s as f64).abs();
if v.is_finite() && v > sample_peak_abs {
sample_peak_abs = v;
}
}
let Some(ref mut ebu) = ebu else {
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
return None;
};
match ebu.add_frames_f32(samples.samples()) {
Ok(_) => fed_any_frames = true,
Err(e) => {
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", e);
return None;
}
}
loop_i = loop_i.wrapping_add(1);
if loop_i % 128 == 0 {
std::thread::yield_now();
}
}
if !fed_any_frames {
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
return None;
}
let Some(ebu) = ebu else {
crate::app_deprintln!("[analysis] loudness failed: no decoder output");
return None;
};
let integrated_lufs = match ebu.loudness_global() {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] loudness_global failed: {}", e);
return None;
}
};
if !integrated_lufs.is_finite() {
crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite");
return None;
}
let mut true_peak = 0.0_f64;
let mut true_peak_ok = true;
for ch in 0..ebu_channels {
match ebu.true_peak(ch) {
Ok(v) if v.is_finite() && v > true_peak => true_peak = v,
Ok(_) => {}
Err(e) => {
true_peak_ok = false;
crate::app_deprintln!("[analysis] true_peak unavailable: {}", e);
break;
}
}
}
if !true_peak_ok {
// Fallback to sample peak if true-peak is not available for this stream/codec.
true_peak = sample_peak_abs;
}
let recommended_gain_db = recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
}
fn analysis_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let base = app
.path()
.app_config_dir()
.map_err(|e| e.to_string())?;
Ok(base.join("audio-analysis.sqlite"))
}
fn configure_connection(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
Ok(())
}
fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS analysis_track (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
status TEXT NOT NULL,
waveform_algo_version INTEGER NOT NULL,
loudness_algo_version INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS waveform_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
bins BLOB NOT NULL,
bin_count INTEGER NOT NULL,
is_partial INTEGER NOT NULL,
known_until_sec REAL NOT NULL,
duration_sec REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb)
);
CREATE TABLE IF NOT EXISTS loudness_cache (
track_id TEXT NOT NULL,
md5_16kb TEXT NOT NULL,
integrated_lufs REAL NOT NULL,
true_peak REAL NOT NULL,
recommended_gain_db REAL NOT NULL,
target_lufs REAL NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (track_id, md5_16kb, target_lufs)
);
CREATE INDEX IF NOT EXISTS idx_analysis_track_status
ON analysis_track(status);
"#,
)?;
Ok(())
}
+613 -16
View File
@@ -21,7 +21,41 @@ use symphonia::core::{
units::{self, Time},
};
use futures_util::StreamExt;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PartialWaveformPayload {
track_id: Option<String>,
bins: Vec<u8>,
known_until_sec: f64,
duration_sec: f64,
is_partial: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PartialLoudnessPayload {
track_id: Option<String>,
gain_db: f32,
target_lufs: f32,
is_partial: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct WaveformUpdatedPayload {
track_id: String,
is_partial: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct NormalizationStatePayload {
engine: String,
current_gain_db: Option<f32>,
target_lufs: f32,
}
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
@@ -1021,17 +1055,20 @@ async fn track_download_task(
gen: u64,
gen_arc: Arc<AtomicU64>,
http_client: reqwest::Client,
app: AppHandle,
url: String,
initial_response: reqwest::Response,
mut prod: HeapProducer<u8>,
done: Arc<AtomicBool>,
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
normalization_target_lufs: Arc<AtomicU32>,
) {
let mut downloaded: u64 = 0;
let mut reconnects: u32 = 0;
let mut next_response: Option<reqwest::Response> = Some(initial_response);
let mut capture: Vec<u8> = Vec::new();
let mut capture_over_limit = false;
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
'outer: loop {
let response = if let Some(r) = next_response.take() {
r
@@ -1120,12 +1157,29 @@ async fn track_download_task(
capture_over_limit = true;
}
}
if !capture_over_limit
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
{
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs);
last_partial_loudness_emit = Instant::now();
}
offset += pushed;
downloaded += pushed as u64;
}
}
}
if !capture_over_limit && !capture.is_empty() {
if let Some(track_id) = playback_identity(&url) {
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) {
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
} else {
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload { track_id, is_partial: false },
);
}
}
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
url: url.clone(),
data: capture,
@@ -1144,12 +1198,15 @@ async fn ranged_download_task(
gen: u64,
gen_arc: Arc<AtomicU64>,
http_client: reqwest::Client,
app: AppHandle,
duration_hint: f64,
url: String,
initial_response: reqwest::Response,
buf: Arc<Mutex<Vec<u8>>>,
downloaded_to: Arc<AtomicUsize>,
done: Arc<AtomicBool>,
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
normalization_target_lufs: Arc<AtomicU32>,
) {
let total_size = buf.lock().unwrap().len();
let mut downloaded: usize = 0;
@@ -1157,6 +1214,8 @@ async fn ranged_download_task(
let mut next_response: Option<reqwest::Response> = Some(initial_response);
let dl_started = Instant::now();
let mut next_progress_mb: usize = 1;
let mut last_partial_emit = Instant::now();
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
'outer: loop {
let response = if let Some(r) = next_response.take() {
@@ -1231,6 +1290,51 @@ async fn ranged_download_task(
}
downloaded += n;
downloaded_to.store(downloaded, Ordering::SeqCst);
if downloaded >= 4096
&& total_size > 0
&& last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded)
{
let bins =
derive_partial_waveform_bins_short_locks(&buf, downloaded, 500);
if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) {
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) {
let _ = app.emit(
"analysis:loudness-partial",
PartialLoudnessPayload {
track_id: playback_identity(&url),
gain_db: provisional_db,
target_lufs,
is_partial: true,
},
);
crate::app_deprintln!(
"[normalization] partial-loudness provisional progress={:.2}% gain_db={:.2} target_lufs={:.2} track_id={:?}",
(downloaded as f32 / total_size as f32) * 100.0,
provisional_db,
target_lufs,
playback_identity(&url)
);
}
last_partial_loudness_emit = Instant::now();
};
let known_until_sec = if duration_hint > 0.0 {
(duration_hint * downloaded as f64 / total_size as f64).clamp(0.0, duration_hint)
} else {
0.0
};
let _ = app.emit(
"analysis:waveform-partial",
PartialWaveformPayload {
track_id: playback_identity(&url),
bins,
known_until_sec,
duration_sec: duration_hint.max(0.0),
is_partial: true,
},
);
last_partial_emit = Instant::now();
}
let mb = downloaded / (1024 * 1024);
if mb >= next_progress_mb {
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
@@ -1262,11 +1366,144 @@ async fn ranged_download_task(
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
let data = buf.lock().unwrap().clone();
if let Some(track_id) = playback_identity(&url) {
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
} else {
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload { track_id, is_partial: false },
);
}
}
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
}
}
/// Wall-clock spacing for `analysis:waveform-partial` — larger buffers cost more
/// to summarize, so we slow emits and keep UI responsive without CPU spikes.
fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration {
const MB: usize = 1024 * 1024;
if downloaded <= 3 * MB {
Duration::from_millis(280)
} else if downloaded <= 10 * MB {
Duration::from_millis(650)
} else {
Duration::from_millis(1100)
}
}
/// Max centered-byte samples examined per bin for partial waveforms (full track
/// analysis still uses dense scans elsewhere). Keeps work O(bin_count × cap).
const PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP: usize = 2048;
fn peak_centered_byte_sampled(region: &[u8]) -> u8 {
if region.is_empty() {
return 0;
}
let cap = PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP;
let mut peak: u8 = 0;
if region.len() <= cap {
for &b in region {
let centered = if b >= 128 { b - 128 } else { 128 - b };
if centered > peak {
peak = centered;
}
}
} else {
let step = (region.len() / cap).max(1);
let mut i = 0;
while i < region.len() {
let b = region[i];
let centered = if b >= 128 { b - 128 } else { 128 - b };
if centered > peak {
peak = centered;
}
i = i.saturating_add(step);
}
let b = region[region.len() - 1];
let centered = if b >= 128 { b - 128 } else { 128 - b };
if centered > peak {
peak = centered;
}
}
peak
}
/// Partial waveform without cloning the whole download buffer and without
/// holding `buf` locked across all bins (that would stall the decoder's `read()`).
fn derive_partial_waveform_bins_short_locks(
buf: &Arc<Mutex<Vec<u8>>>,
downloaded: usize,
bin_count: usize,
) -> Vec<u8> {
if downloaded == 0 || bin_count == 0 {
return Vec::new();
}
let len = downloaded;
let mut out = vec![0u8; bin_count];
for (i, slot) in out.iter_mut().enumerate() {
let start = i * len / bin_count;
let end = ((i + 1) * len / bin_count).max(start + 1).min(len);
let peak = {
let b = buf.lock().unwrap();
if start >= b.len() {
0u8
} else {
let end = end.min(b.len());
peak_centered_byte_sampled(&b[start..end])
}
};
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
}
out
}
fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], target_lufs: f32) {
if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES {
crate::app_deprintln!(
"[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}",
bytes.len(),
PARTIAL_LOUDNESS_MIN_BYTES
);
return;
}
// Lightweight fallback based on buffered bytes count to keep CPU low.
let mb = bytes.len() as f32 / (1024.0 * 1024.0);
let floor_db = (target_lufs + 11.0).clamp(-6.0, -1.5);
let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0);
crate::app_deprintln!(
"[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}",
bytes.len(),
gain_db,
target_lufs,
playback_identity(url)
);
let _ = app.emit(
"analysis:loudness-partial",
PartialLoudnessPayload {
track_id: playback_identity(url),
gain_db: gain_db as f32,
target_lufs,
is_partial: true,
},
);
}
fn provisional_loudness_gain_from_progress(downloaded: usize, total_size: usize, target_lufs: f32) -> Option<f32> {
if total_size == 0 || downloaded == 0 {
return None;
}
let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0);
// Move from startup attenuation toward a more realistic late-stream level.
// This avoids staying near -2 dB and then jumping hard when final LUFS lands.
let start_db = LOUDNESS_STARTUP_ATTENUATION_DB.min(0.0);
let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0);
let shaped = progress.powf(0.75);
Some(start_db + (end_db - start_db) * shaped)
}
fn content_type_to_hint(ct: &str) -> Option<String> {
let ct = ct.to_ascii_lowercase();
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
@@ -1999,6 +2236,10 @@ pub struct AudioEngine {
/// When true, audio_play chains sources to the existing Sink instead of
/// creating a new one, achieving sample-accurate gapless transitions.
pub gapless_enabled: Arc<AtomicBool>,
/// 0=off, 1=replaygain, 2=loudness (future runtime loudness engine).
pub normalization_engine: Arc<AtomicU32>,
/// Target loudness in LUFS for loudness engine (future use).
pub normalization_target_lufs: Arc<AtomicU32>,
/// Info about the next-up chained track (gapless mode).
/// The progress task reads this when `current_source_done` fires.
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
@@ -2015,6 +2256,10 @@ pub struct AudioEngine {
/// Active radio session state. None for regular (non-radio) tracks.
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
pub(crate) radio_state: Mutex<Option<RadioLiveState>>,
/// URL last committed to `AudioCurrent` — used so `audio_update_replay_gain` can
/// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null`
/// (otherwise `compute_gain` would treat that as unity gain and playback "jumps").
pub(crate) current_playback_url: Arc<Mutex<Option<String>>>,
}
pub struct AudioCurrent {
@@ -2190,11 +2435,13 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
// Set PipeWire / PulseAudio latency hints before the first open.
#[cfg(target_os = "linux")]
{
// Match cpal ALSA ~200 ms headroom: larger quantum reduces underruns when
// the decoder thread catches up after seek or competes with other work.
if std::env::var("PIPEWIRE_LATENCY").is_err() {
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
std::env::set_var("PIPEWIRE_LATENCY", "8192/48000");
}
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
std::env::set_var("PULSE_LATENCY_MSEC", "85");
std::env::set_var("PULSE_LATENCY_MSEC", "170");
}
}
@@ -2276,12 +2523,15 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(0)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
radio_state: Mutex::new(None),
current_playback_url: Arc::new(Mutex::new(None)),
};
(engine, thread)
@@ -2343,6 +2593,101 @@ fn same_playback_target(a_url: &str, b_url: &str) -> bool {
}
}
fn resolve_loudness_gain_from_cache(
app: &AppHandle,
url: &str,
target_lufs: f32,
requested_loudness_gain_db: Option<f32>,
) -> Option<f32> {
// Never trust `requested` alone: the frontend may pass the *next* track's gain
// while `current_playback_url` still lags one play behind. Always prefer a
// cache row for **this** URL's track_id; use `requested` only on cache miss
// (provisional / pre-seed from JS).
let Some(track_id) = playback_identity(url) else {
if let Some(r) = requested_loudness_gain_db {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=request-no-identity arg={:.4}",
r
);
}
return requested_loudness_gain_db;
};
let Some(cache) = app.try_state::<crate::analysis_cache::AnalysisCache>() else {
if let Some(r) = requested_loudness_gain_db {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=request-no-cache arg={:.4} track_id={}",
r,
track_id
);
}
return requested_loudness_gain_db;
};
// Also touch waveform row here so playback path verifies current context is present.
let _ = cache.get_latest_waveform_for_track(&track_id);
match cache.get_latest_loudness_for_track(&track_id) {
Ok(Some(row)) if row.integrated_lufs.is_finite() => {
let recommended = crate::analysis_cache::recommended_gain_for_target(
row.integrated_lufs,
row.true_peak,
target_lufs as f64,
) as f32;
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=cache track_id={} gain_db={:.2} target_lufs={:.2} integrated_lufs={:.2} updated_at={}",
track_id,
recommended,
target_lufs,
row.integrated_lufs,
row.updated_at
);
Some(recommended)
}
Ok(Some(row)) => {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=cache-invalid track_id={} integrated_lufs={}",
track_id,
row.integrated_lufs
);
None
}
Ok(None) => {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=cache-miss track_id={}",
track_id
);
if let Some(r) = requested_loudness_gain_db {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=request-fallback track_id={} arg={:.4}",
track_id,
r
);
Some(r)
} else {
None
}
}
Err(e) => {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=cache-error track_id={} err={}",
track_id,
e
);
None
}
}
}
/// LUFS mode: use cache / explicit `requested`, else a **conservative** trim until
/// analysis exists — must never return `None` here or `compute_gain` uses unity.
fn loudness_gain_db_or_startup(
app: &AppHandle,
url: &str,
target_lufs: f32,
requested: Option<f32>,
) -> Option<f32> {
resolve_loudness_gain_from_cache(app, url, target_lufs, requested)
.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB))
}
/// Take (consume) completed manual-stream bytes if they correspond to `url`.
pub(crate) fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option<Vec<u8>> {
let mut guard = state.stream_completed_cache.lock().unwrap();
@@ -2439,23 +2784,104 @@ async fn fetch_data(
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
const MASTER_HEADROOM: f32 = 0.891_254;
const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024;
const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 350;
/// Until integrated LUFS is known, stay clearly below "full" level so a follow-up
/// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor.
const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0;
fn compute_gain(
normalization_engine: u32,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
volume: f32,
) -> (f32, f32) {
let gain_linear = replay_gain_db
.map(|db| 10f32.powf((db + pre_gain_db) / 20.0))
.unwrap_or_else(|| 10f32.powf(fallback_db / 20.0));
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
let gain_linear = match normalization_engine {
2 => loudness_gain_db
.map(|db| 10f32.powf(db / 20.0))
.unwrap_or(1.0),
1 => replay_gain_db
.map(|db| 10f32.powf((db + pre_gain_db) / 20.0))
.unwrap_or_else(|| 10f32.powf(fallback_db / 20.0)),
_ => 1.0,
};
let peak = if normalization_engine == 1 {
replay_gain_peak.unwrap_or(1.0).max(0.001)
} else {
1.0
};
let gain_linear = gain_linear.min(1.0 / peak);
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
(gain_linear, effective)
}
fn normalization_engine_name(mode: u32) -> &'static str {
match mode {
1 => "replaygain",
2 => "loudness",
_ => "off",
}
}
fn gain_linear_to_db(gain_linear: f32) -> Option<f32> {
if gain_linear.is_finite() && gain_linear > 0.0 {
Some(20.0 * gain_linear.log10())
} else {
None
}
}
/// `audio:normalization-state` “Now dB” for the UI: omit a number while loudness
/// mode is still on the **startup safety trim** only (no cache row / no explicit
/// requested gain from analysis), so users do not read `-6 dB` as measured LUFS.
fn loudness_ui_current_gain_db(
norm_mode: u32,
resolved_loudness_gain_db: Option<f32>,
gain_linear: f32,
) -> Option<f32> {
if norm_mode == 2 && resolved_loudness_gain_db.is_none() {
None
} else {
gain_linear_to_db(gain_linear)
}
}
fn ramp_sink_volume(sink: Arc<Sink>, from: f32, to: f32) {
let from = from.clamp(0.0, 1.0);
let to = to.clamp(0.0, 1.0);
if (to - from).abs() < 0.002 {
sink.set_volume(to);
return;
}
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
std::thread::spawn(move || {
let delta = (to - from).abs();
// Stretch large corrections to avoid audible "step down" moments.
let (steps, step_ms): (usize, u64) = if delta > 0.30 {
(24, 35)
} else if delta > 0.18 {
(18, 30)
} else if delta > 0.10 {
(14, 24)
} else {
(8, 16)
};
for i in 1..=steps {
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
let t = i as f32 / steps as f32;
let v = from + (to - from) * t;
sink.set_volume(v.clamp(0.0, 1.0));
std::thread::sleep(Duration::from_millis(step_ms));
}
});
}
// ─── Commands ─────────────────────────────────────────────────────────────────
#[tauri::command]
@@ -2465,6 +2891,7 @@ pub async fn audio_play(
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
@@ -2541,6 +2968,11 @@ pub async fn audio_play(
old.stop();
}
// Pin the logical playback URL immediately so `audio_update_replay_gain` (e.g. from
// a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not
// the previous URL still stored until the sink swap completes.
*state.current_playback_url.lock().unwrap() = Some(url.clone());
// Extract format hint from URL for better symphonia probing. Strip the
// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
// don't latch onto random query-param substrings; only accept short
@@ -2646,7 +3078,11 @@ pub async fn audio_play(
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if let (true, Some(total)) = (supports_range, total_size) {
// Guardrail: when format/container hint is unknown, some demuxers may
// seek near EOF during probe. With a progressively downloaded ranged
// source that can delay first audible samples until most/all bytes are
// fetched. Prefer sequential streaming in that case for faster start.
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
let total_usize = total as usize;
crate::app_deprintln!(
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
@@ -2660,12 +3096,15 @@ pub async fn audio_play(
gen,
state.generation.clone(),
audio_http_client(&state),
app.clone(),
duration_hint,
url.clone(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.normalization_target_lufs.clone(),
));
let reader = RangedHttpSource {
buf,
@@ -2683,8 +3122,8 @@ pub async fn audio_play(
}
} else {
crate::app_deprintln!(
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}",
supports_range, total_size
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range, total_size, stream_hint
);
let buffer_cap = total_size
.map(|n| n as usize)
@@ -2697,11 +3136,13 @@ pub async fn audio_play(
gen,
state.generation.clone(),
audio_http_client(&state),
app.clone(),
url.clone(),
response,
prod,
done.clone(),
state.stream_completed_cache.clone(),
state.normalization_target_lufs.clone(),
));
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
@@ -2735,7 +3176,45 @@ pub async fn audio_play(
return Ok(());
}
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
let resolved_loudness_gain_db = resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db);
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let startup_loudness_gain_db = if norm_mode == 2 {
loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db)
} else {
resolved_loudness_gain_db
};
let (gain_linear, effective_volume) = compute_gain(
norm_mode,
replay_gain_db,
replay_gain_peak,
startup_loudness_gain_db,
pre_gain_db,
fallback_db,
volume,
);
let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear);
crate::app_deprintln!(
"[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}",
playback_identity(&url),
normalization_engine_name(norm_mode),
replay_gain_db,
replay_gain_peak,
resolved_loudness_gain_db,
gain_linear,
current_gain_db,
target_lufs,
volume,
effective_volume
);
let _ = app.emit(
"audio:normalization-state",
NormalizationStatePayload {
engine: normalization_engine_name(norm_mode).to_string(),
current_gain_db,
target_lufs,
},
);
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
@@ -2999,6 +3478,7 @@ pub async fn audio_play(
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
state.current_playback_url.clone(),
);
Ok(())
@@ -3020,9 +3500,11 @@ pub async fn audio_chain_preload(
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
hi_res_enabled: bool,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Idempotent: already chained this track → nothing to do.
@@ -3087,7 +3569,22 @@ pub async fn audio_chain_preload(
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
// the still-playing current source). Volume for the chained track is
// applied at the gapless transition in `spawn_progress_task`, not here.
let (gain_linear, _effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let chain_loudness_db = if norm_mode == 2 {
loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db)
} else {
resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db)
};
let (gain_linear, _effective_volume) = compute_gain(
norm_mode,
replay_gain_db,
replay_gain_peak,
chain_loudness_db,
pre_gain_db,
fallback_db,
volume,
);
let done_next = Arc::new(AtomicBool::new(false));
// Use a dedicated counter for the chained source — it will be swapped into
@@ -3189,6 +3686,7 @@ fn spawn_progress_task(
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
gapless_switch_at: Arc<AtomicU64>,
current_playback_url: Arc<Mutex<Option<String>>>,
) {
tokio::spawn(async move {
let mut near_end_ticks: u32 = 0;
@@ -3239,6 +3737,7 @@ fn spawn_progress_task(
// must only be called at the boundary, not at preload.
{
let mut cur = current_arc.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.replay_gain_linear = info.replay_gain_linear;
cur.base_volume = info.base_volume;
cur.duration_secs = info.duration_secs;
@@ -3246,10 +3745,12 @@ fn spawn_progress_task(
cur.play_started = Some(Instant::now());
if let Some(sink) = &cur.sink {
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
sink.set_volume(effective);
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
}
}
*current_playback_url.lock().unwrap() = Some(info.url.clone());
// Record the gapless switch timestamp for ghost-command guard.
let switch_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -3411,6 +3912,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
#[tauri::command]
pub fn audio_stop(state: State<'_, AudioEngine>) {
state.generation.fetch_add(1, Ordering::SeqCst);
*state.current_playback_url.lock().unwrap() = None;
*state.chained_info.lock().unwrap() = None;
*state.stream_completed_cache.lock().unwrap() = None;
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
@@ -3525,9 +4027,11 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
#[tauri::command]
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
sink.set_volume((cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0));
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
}
}
@@ -3536,17 +4040,67 @@ pub fn audio_update_replay_gain(
volume: f32,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
loudness_gain_db: Option<f32>,
pre_gain_db: f32,
fallback_db: f32,
app: AppHandle,
state: State<'_, AudioEngine>,
) {
let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
let url_for_loudness = if norm_mode == 2 {
state.current_playback_url.lock().unwrap().clone()
} else {
None
};
let resolved_loudness_gain_db = url_for_loudness
.as_deref()
.and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db));
let effective_loudness_db = if norm_mode == 2 {
match url_for_loudness.as_deref() {
Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db),
None => loudness_gain_db.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)),
}
} else {
loudness_gain_db
};
let (gain_linear, effective) = compute_gain(
norm_mode,
replay_gain_db,
replay_gain_peak,
effective_loudness_db,
pre_gain_db,
fallback_db,
volume,
);
let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear);
crate::app_deprintln!(
"[normalization] audio_update_replay_gain engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective={:.3}",
normalization_engine_name(norm_mode),
replay_gain_db,
replay_gain_peak,
loudness_gain_db,
gain_linear,
current_gain_db,
target_lufs,
volume,
effective
);
let mut cur = state.current.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
sink.set_volume(effective);
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
}
let _ = app.emit(
"audio:normalization-state",
NormalizationStatePayload {
engine: normalization_engine_name(norm_mode).to_string(),
current_gain_db,
target_lufs,
},
);
}
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
@@ -3636,6 +4190,16 @@ pub async fn audio_preload(
response.bytes().await.map_err(|e| e.to_string())?.into()
};
let _ = duration_hint; // kept in API for compatibility
if let Some(track_id) = playback_identity(&url) {
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
} else {
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload { track_id, is_partial: false },
);
}
}
let url_for_emit = url.clone();
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
let _ = app.emit("audio:preload-ready", url_for_emit);
@@ -3780,6 +4344,8 @@ pub async fn audio_play_radio(
cur.fadeout_samples = Some(fadeout_samples);
}
*state.current_playback_url.lock().unwrap() = Some(url.clone());
state.current_sample_rate.store(sample_rate, Ordering::Relaxed);
state.current_channels.store(channels as u32, Ordering::Relaxed);
@@ -3798,6 +4364,7 @@ pub async fn audio_play_radio(
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
state.current_playback_url.clone(),
);
Ok(())
@@ -3980,6 +4547,36 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
#[tauri::command]
pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state: State<'_, AudioEngine>) {
let mode = match engine.as_str() {
"replaygain" => 1,
"loudness" => 2,
_ => 0,
};
state.normalization_engine.store(mode, Ordering::Relaxed);
let target = target_lufs.clamp(-30.0, -8.0);
state
.normalization_target_lufs
.store(target.to_bits(), Ordering::Relaxed);
crate::app_deprintln!(
"[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2}",
engine,
normalization_engine_name(mode),
target
);
let _ = app.emit(
"audio:normalization-state",
NormalizationStatePayload {
engine: normalization_engine_name(mode).to_string(),
// At mode-switch time the effective track gain may not be recalculated yet.
// Emit `None` and let audio_play/audio_update_replay_gain publish actual value.
current_gain_db: None,
target_lufs: target,
},
);
}
// ─── Device-change watcher ────────────────────────────────────────────────────
//
// Polls every 3 s for two conditions:
+214
View File
@@ -2,6 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio;
mod analysis_cache;
pub mod cli;
mod discord;
pub(crate) mod logging;
@@ -59,6 +60,12 @@ fn sync_cancel_flags() -> &'static Mutex<HashMap<String, Arc<AtomicBool>>> {
FLAGS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Tracks analysis backfill jobs already running per track_id.
fn analysis_backfill_inflight() -> &'static Mutex<std::collections::HashSet<String>> {
static SET: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
SET.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
}
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
type TrayState = Mutex<Option<TrayIcon>>;
@@ -67,6 +74,34 @@ type TrayState = Mutex<Option<TrayIcon>>;
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct WaveformCachePayload {
bins: Vec<u8>,
bin_count: i64,
is_partial: bool,
known_until_sec: f64,
duration_sec: f64,
updated_at: i64,
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct WaveformUpdatedPayload {
track_id: String,
is_partial: bool,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct LoudnessCachePayload {
integrated_lufs: f64,
true_peak: f64,
recommended_gain_db: f64,
target_lufs: f64,
updated_at: i64,
}
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
@@ -1131,6 +1166,126 @@ fn check_dir_accessible(path: String) -> bool {
std::path::Path::new(&path).is_dir()
}
#[tauri::command]
fn analysis_get_waveform(
track_id: String,
md5_16kb: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let key = analysis_cache::TrackKey { track_id, md5_16kb };
let row = cache.get_waveform(&key)?;
Ok(row.map(|v| WaveformCachePayload {
bins: v.bins,
bin_count: v.bin_count,
is_partial: v.is_partial,
known_until_sec: v.known_until_sec,
duration_sec: v.duration_sec,
updated_at: v.updated_at,
}))
}
#[tauri::command]
fn analysis_get_waveform_for_track(
track_id: String,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<WaveformCachePayload>, String> {
let row = cache.get_latest_waveform_for_track(&track_id)?;
Ok(row.map(|v| WaveformCachePayload {
bins: v.bins,
bin_count: v.bin_count,
is_partial: v.is_partial,
known_until_sec: v.known_until_sec,
duration_sec: v.duration_sec,
updated_at: v.updated_at,
}))
}
#[tauri::command]
fn analysis_get_loudness_for_track(
track_id: String,
target_lufs: Option<f64>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Option<LoudnessCachePayload>, String> {
let row = cache.get_latest_loudness_for_track(&track_id)?;
Ok(row.map(|v| {
let requested_target = target_lufs.unwrap_or(v.target_lufs).clamp(-30.0, -8.0);
let recommended_gain_db = analysis_cache::recommended_gain_for_target(
v.integrated_lufs,
v.true_peak,
requested_target,
);
LoudnessCachePayload {
integrated_lufs: v.integrated_lufs,
true_peak: v.true_peak,
recommended_gain_db,
target_lufs: requested_target,
updated_at: v.updated_at,
}}))
}
#[tauri::command]
fn analysis_enqueue_seed_from_url(
track_id: String,
url: String,
app: tauri::AppHandle,
) -> Result<(), String> {
if track_id.trim().is_empty() || url.trim().is_empty() {
return Ok(());
}
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
track_id
);
return Ok(());
}
}
{
let mut inflight = analysis_backfill_inflight()
.lock()
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
if inflight.contains(&track_id) {
return Ok(());
}
inflight.insert(track_id.clone());
}
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
crate::app_deprintln!("[analysis] backfill queued: {}", track_id);
let result = async {
let client = reqwest::Client::builder()
.user_agent(subsonic_wire_user_agent())
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Err("empty response".to_string());
}
let has_loudness = enqueue_analysis_seed(&app_clone, &track_id, &bytes)?;
Ok::<bool, String>(has_loudness)
}
.await;
match result {
Ok(has_loudness) => crate::app_deprintln!(
"[analysis] backfill ready: {} (has_loudness={})",
track_id,
has_loudness
),
Err(e) => crate::app_eprintln!("[analysis] backfill failed for {}: {}", track_id, e),
}
if let Ok(mut inflight) = analysis_backfill_inflight().lock() {
inflight.remove(&track_id);
}
});
Ok(())
}
// ─── Offline Track Cache ──────────────────────────────────────────────────────
/// Streams an HTTP response body directly to `dest_path` in small chunks.
@@ -1151,6 +1306,47 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path
Ok(())
}
fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
analysis_cache::seed_from_bytes(app, track_id, bytes)
.map_err(|e| {
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
e
})?;
let _ = app.emit(
"analysis:waveform-updated",
WaveformUpdatedPayload {
track_id: track_id.to_string(),
is_partial: false,
},
);
let has_loudness = app
.try_state::<analysis_cache::AnalysisCache>()
.and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten())
.is_some();
crate::app_deprintln!(
"[analysis] seed result track_id={} bytes={} has_loudness={}",
track_id,
bytes.len(),
has_loudness
);
Ok(has_loudness)
}
async fn enqueue_analysis_seed_from_file(
app: &tauri::AppHandle,
track_id: &str,
file_path: &std::path::Path,
) {
let bytes = match tokio::fs::read(file_path).await {
Ok(v) => v,
Err(_) => return,
};
if bytes.is_empty() {
return;
}
let _ = enqueue_analysis_seed(app, track_id, &bytes);
}
/// Downloads a single track to the app's offline cache directory.
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
@@ -1217,6 +1413,8 @@ async fn download_track_offline(
.await
.map_err(|e| e.to_string())?;
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
Ok(path_str)
}
@@ -1765,6 +1963,8 @@ async fn download_track_hot_cache(
.await
.map_err(|e| e.to_string())?;
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
@@ -1818,6 +2018,8 @@ async fn promote_stream_cache_to_hot_cache(
.await
.map_err(|e| e.to_string())?;
let _ = enqueue_analysis_seed(&app, &track_id, &bytes);
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
@@ -3582,6 +3784,13 @@ pub fn run() {
}))
.setup(|app| {
// ── Analysis cache (SQLite) ───────────────────────────────────
{
let cache = analysis_cache::AnalysisCache::init(&app.handle())
.map_err(|e| format!("analysis cache init failed: {e}"))?;
app.manage(cache);
}
// ── Custom title bar on Linux ─────────────────────────────────
// Remove OS window decorations on all Linux so the React TitleBar
// can take over. The frontend checks is_tiling_wm() to decide
@@ -3820,6 +4029,7 @@ pub fn run() {
audio::audio_play_radio,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_set_normalization,
audio::audio_list_devices,
audio::audio_canonicalize_selected_device,
audio::audio_default_output_device_name,
@@ -3850,6 +4060,10 @@ pub fn run() {
fetch_json_url,
fetch_icy_metadata,
resolve_stream_url,
analysis_get_waveform,
analysis_get_waveform_for_track,
analysis_get_loudness_for_track,
analysis_enqueue_seed_from_url,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
+22
View File
@@ -258,6 +258,9 @@ export default function QueuePanel() {
const contextMenu = usePlayerStore(s => s.contextMenu);
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
@@ -267,6 +270,8 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
const replayGainMode = useAuthStore(s => s.replayGainMode);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
@@ -478,6 +483,14 @@ export default function QueuePanel() {
const rgParts = formatQueueReplayGainParts(currentTrack, t);
const baseLine = baseParts.join(' · ');
const rgLine = rgParts.join(' · ');
const liveGainLabel = normalizationNowDb != null
? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`
: '—';
const targetLabel = normalizationEngineLive === 'loudness'
? (normalizationTargetLufs != null ? `${normalizationTargetLufs} LUFS` : '-16 LUFS')
: normalizationEngineLive === 'replaygain'
? `ReplayGain (${replayGainMode})`
: t('queue.normOff', { defaultValue: 'Off' });
if (!baseLine && !rgLine && !playbackSource) return null;
const showRgLine = expandReplayGain && !!rgLine;
return (
@@ -522,6 +535,15 @@ export default function QueuePanel() {
{' · '}{rgLine}
</span>
)}
{(normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness') && (
<span className="queue-current-tech-rg">
<span className="queue-current-tech-rg-label">{t('queue.normNow', { defaultValue: 'Now' })}</span>
{' · '}{liveGainLabel}
{' · '}
<span className="queue-current-tech-rg-label">{t('queue.normTarget', { defaultValue: 'Target' })}</span>
{' · '}{targetLabel}
</span>
)}
</div>
</div>
);
+40 -1
View File
@@ -824,6 +824,7 @@ export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const lastHeightsTrackIdRef = useRef<string | undefined>(undefined);
const progressRef = useRef(usePlayerStore.getState().progress);
const bufferedRef = useRef(usePlayerStore.getState().buffered);
const isDragging = useRef(false);
@@ -832,6 +833,10 @@ export default function WaveformSeek({ trackId }: Props) {
const [hoverPct, setHoverPct] = useState<number | null>(null);
const seek = usePlayerStore(s => s.seek);
const waveformBins = usePlayerStore(s => s.waveformBins);
const waveformIsPartial = usePlayerStore(s => s.waveformIsPartial);
const waveformKnownUntilSec = usePlayerStore(s => s.waveformKnownUntilSec);
const waveformDurationSec = usePlayerStore(s => s.waveformDurationSec);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
@@ -843,10 +848,44 @@ export default function WaveformSeek({ trackId }: Props) {
useEffect(() => {
if (!trackId) {
heightsRef.current = null;
lastHeightsTrackIdRef.current = undefined;
return;
}
if (waveformBins && waveformBins.length > 0) {
const src = waveformBins;
const h = new Float32Array(BAR_COUNT);
const effectiveDur = waveformDurationSec > 0 ? waveformDurationSec : duration;
const knownFrac = waveformIsPartial && effectiveDur > 0
? Math.max(0, Math.min(1, waveformKnownUntilSec / effectiveDur))
: 1;
const knownBars = Math.max(0, Math.min(BAR_COUNT, Math.floor(knownFrac * BAR_COUNT)));
for (let i = 0; i < BAR_COUNT; i++) {
if (i >= knownBars) {
h[i] = 0.08; // unknown tail baseline while partial waveform is still loading
continue;
}
const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length));
const v = src[idx];
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
}
// Partial -> full handoff: blend with previous heights for a smoother
// visual transition and avoid a hard "jump" in the waveform shape.
if (lastHeightsTrackIdRef.current === trackId && heightsRef.current && heightsRef.current.length === BAR_COUNT) {
const prev = heightsRef.current;
const mixed = new Float32Array(BAR_COUNT);
for (let i = 0; i < BAR_COUNT; i++) {
mixed[i] = prev[i] * 0.45 + h[i] * 0.55;
}
heightsRef.current = mixed;
} else {
heightsRef.current = h;
}
lastHeightsTrackIdRef.current = trackId;
return;
}
heightsRef.current = makeHeights(trackId);
}, [trackId]);
lastHeightsTrackIdRef.current = trackId;
}, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]);
// Imperative subscription — no React re-renders from progress changes.
// Static styles draw here; animated styles only update refs.
+63 -3
View File
@@ -2224,12 +2224,71 @@ export default function Settings() {
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
<input type="checkbox" checked={auth.replayGainEnabled} onChange={e => auth.setReplayGainEnabled(e.target.checked)} id="replay-gain-toggle" />
<input
type="checkbox"
checked={auth.replayGainEnabled}
onChange={e => {
const on = e.target.checked;
auth.setReplayGainEnabled(on);
if (!on) auth.setNormalizationEngine('off');
else if (auth.normalizationEngine === 'off') auth.setNormalizationEngine('replaygain');
}}
id="replay-gain-toggle"
/>
<span className="toggle-track" />
</label>
</div>
{auth.replayGainEnabled && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Engine:</span>
<button
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setNormalizationEngine('replaygain')}
>
ReplayGain
</button>
<button
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setNormalizationEngine('loudness')}
>
Loudness
</button>
</div>
{auth.normalizationEngine === 'loudness' && (
<>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Apple-like automatic normalization. ReplayGain tags are ignored in this mode.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Target LUFS:</span>
<button
className={`btn ${auth.loudnessTargetLufs === -16 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-16)}
>
-16
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -14 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-14)}
>
-14
</button>
<button
className={`btn ${auth.loudnessTargetLufs === -12 ? 'btn-primary' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setLoudnessTargetLufs(-12)}
>
-12
</button>
</div>
</>
)}
{auth.normalizationEngine === 'replaygain' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
<button
@@ -2254,14 +2313,15 @@ export default function Settings() {
{t('settings.replayGainAlbum')}
</button>
</div>
{auth.replayGainMode === 'auto' && (
)}
{auth.normalizationEngine === 'replaygain' && auth.replayGainMode === 'auto' && (
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.replayGainAutoDesc')}
</div>
)}
</div>
)}
{auth.replayGainEnabled && (
{auth.replayGainEnabled && auth.normalizationEngine === 'replaygain' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
+29
View File
@@ -0,0 +1,29 @@
export type AnalysisStorageChangedReason =
| 'offline-delete'
| 'hotcache-delete'
| 'hotcache-purge';
export type AnalysisStorageChangedDetail = {
trackId?: string | null;
reason: AnalysisStorageChangedReason;
};
const EVENT_NAME = 'psysonic:analysis-storage-changed';
export function emitAnalysisStorageChanged(detail: AnalysisStorageChangedDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<AnalysisStorageChangedDetail>(EVENT_NAME, { detail }));
}
export function onAnalysisStorageChanged(
listener: (detail: AnalysisStorageChangedDetail) => void,
): () => void {
if (typeof window === 'undefined') return () => {};
const wrapped = (evt: Event) => {
const ce = evt as CustomEvent<AnalysisStorageChangedDetail>;
if (!ce?.detail) return;
listener(ce.detail);
};
window.addEventListener(EVENT_NAME, wrapped as EventListener);
return () => window.removeEventListener(EVENT_NAME, wrapped as EventListener);
}
+15
View File
@@ -20,6 +20,7 @@ export interface ServerProfile {
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
export type LoggingMode = 'off' | 'normal' | 'debug';
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
@@ -49,6 +50,8 @@ interface AuthState {
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
replayGainEnabled: boolean;
normalizationEngine: NormalizationEngine;
loudnessTargetLufs: -16 | -14 | -12;
replayGainMode: 'track' | 'album' | 'auto';
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
@@ -205,6 +208,8 @@ interface AuthState {
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
setNormalizationEngine: (v: NormalizationEngine) => void;
setLoudnessTargetLufs: (v: -16 | -14 | -12) => void;
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
setReplayGainPreGainDb: (v: number) => void;
setReplayGainFallbackDb: (v: number) => void;
@@ -315,6 +320,8 @@ export const useAuthStore = create<AuthState>()(
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
normalizationEngine: 'off',
loudnessTargetLufs: -14,
replayGainMode: 'auto',
replayGainPreGainDb: 0,
replayGainFallbackDb: 0,
@@ -442,6 +449,14 @@ export const useAuthStore = create<AuthState>()(
set({ replayGainEnabled: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setNormalizationEngine: (v) => {
set({ normalizationEngine: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setLoudnessTargetLufs: (v) => {
set({ loudnessTargetLufs: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
},
setReplayGainMode: (v) => {
set({ replayGainMode: v });
usePlayerStore.getState().updateReplayGainForCurrentTrack();
+4
View File
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
import type { Track } from './playerStore';
import { emitAnalysisStorageChanged } from './analysisSync';
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
@@ -96,6 +97,7 @@ export const useHotCacheStore = create<HotCacheState>()(
delete next[entryKey(serverId, trackId)];
return { entries: next };
});
emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' });
},
totalBytes: () =>
@@ -167,6 +169,7 @@ export const useHotCacheStore = create<HotCacheState>()(
}).catch(() => {});
sum -= meta.sizeBytes || 0;
delete entries[key];
emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' });
}
set({ entries });
@@ -175,6 +178,7 @@ export const useHotCacheStore = create<HotCacheState>()(
clearAllDisk: async (customDir: string | null) => {
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
set({ entries: {} });
emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' });
},
}),
{
+4
View File
@@ -6,6 +6,7 @@ import type { SubsonicSong } from '../api/subsonic';
import { useAuthStore } from './authStore';
import { showToast } from '../utils/toast';
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
import { emitAnalysisStorageChanged } from './analysisSync';
export interface OfflineTrackMeta {
id: string;
@@ -299,6 +300,9 @@ export const useOfflineStore = create<OfflineState>()(
}).catch(() => {});
}),
);
for (const trackId of album.trackIds) {
emitAnalysisStorageChanged({ trackId, reason: 'offline-delete' });
}
set(state => {
const tracks = { ...state.tracks };
+447 -13
View File
@@ -10,6 +10,7 @@ import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTr
import { useAuthStore } from './authStore';
import { useOfflineStore } from './offlineStore';
import { useHotCacheStore } from './hotCacheStore';
import { onAnalysisStorageChanged } from './analysisSync';
export interface Track {
id: string;
@@ -143,6 +144,19 @@ async function buildInfiniteQueueCandidates(
interface PlayerState {
currentTrack: Track | null;
waveformBins: number[] | null;
waveformIsPartial: boolean;
waveformKnownUntilSec: number;
waveformDurationSec: number;
normalizationNowDb: number | null;
normalizationTargetLufs: number | null;
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
normalizationDbgSource: string | null;
normalizationDbgTrackId: string | null;
normalizationDbgCacheGainDb: number | null;
normalizationDbgCacheTargetLufs: number | null;
normalizationDbgCacheUpdatedAt: number | null;
normalizationDbgLastEventAt: number | null;
currentRadio: InternetRadioStation | null;
/** Latches the source used to start the currently playing track. */
currentPlaybackSource: PlaybackSourceKind | null;
@@ -241,6 +255,29 @@ interface PlayerState {
closeSongInfo: () => void;
}
type WaveformCachePayload = {
bins: number[];
binCount: number;
isPartial: boolean;
knownUntilSec: number;
durationSec: number;
updatedAt: number;
};
type LoudnessCachePayload = {
integratedLufs: number;
truePeak: number;
recommendedGainDb: number;
targetLufs: number;
updatedAt: number;
};
type NormalizationStatePayload = {
engine: 'off' | 'replaygain' | 'loudness' | string;
currentGainDb: number | null;
targetLufs: number;
};
// ─── Module-level playback primitives ─────────────────────────────────────────
// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track.
@@ -259,6 +296,66 @@ let radioFetching = false;
// Artist ID used to start the current radio session — persists across track
// advances so proactive loading works even when songs lack artistId.
let currentRadioArtistId: string | null = null;
let cachedLoudnessGainByTrackId: Record<string, number> = {};
let stableLoudnessGainByTrackId: Record<string, true> = {};
let lastNormalizationUiUpdateAtMs = 0;
function emitNormalizationDebug(step: string, details?: Record<string, unknown>) {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'normalization',
message: JSON.stringify({ step, details }),
}).catch(() => {});
}
function normalizeAnalysisTrackId(trackId?: string | null): string | null {
if (!trackId) return null;
if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length);
return trackId;
}
function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return Math.abs(a - b) <= eps;
}
function deriveNormalizationSnapshot(
track: Track,
queue: Track[],
queueIndex: number,
): Pick<
PlayerState,
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
> {
const auth = useAuthStore.getState();
const engine = auth.normalizationEngine;
if (engine === 'loudness') {
const target = auth.loudnessTargetLufs;
return {
// Clears stale UI until `audio:normalization-state` / refresh catches up.
normalizationNowDb: null,
normalizationTargetLufs: target,
normalizationEngineLive: 'loudness',
};
}
if (engine === 'replaygain' && auth.replayGainEnabled) {
const prev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const resolved = resolveReplayGainDb(track, prev, next, true, auth.replayGainMode);
const nowDb = resolved != null ? (resolved + auth.replayGainPreGainDb) : auth.replayGainFallbackDb;
return {
normalizationNowDb: nowDb,
normalizationTargetLufs: null,
normalizationEngineLive: 'replaygain',
};
}
return {
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
};
}
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
@@ -267,6 +364,9 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
let seekTarget: number | null = null;
let seekTargetSetAt = 0;
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
const analysisBackfillInFlightByTrackId: Record<string, true> = {};
const analysisBackfillAttemptsByTrackId: Record<string, number> = {};
const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2;
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
let seekFallbackRetryStartedAt = 0;
@@ -481,6 +581,102 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
useHotCacheStore.getState().touchPlayed(trackId, serverId);
}
function isReplayGainActive() {
const a = useAuthStore.getState();
return a.normalizationEngine === 'replaygain' && a.replayGainEnabled;
}
async function refreshWaveformForTrack(trackId: string) {
if (!trackId) return;
try {
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
if (!row || !Array.isArray(row.bins) || row.bins.length === 0) {
usePlayerStore.setState({
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
});
return;
}
usePlayerStore.setState({
waveformBins: row.bins,
waveformIsPartial: !!row.isPartial,
waveformKnownUntilSec: Number.isFinite(row.knownUntilSec) ? row.knownUntilSec : 0,
waveformDurationSec: Number.isFinite(row.durationSec) ? row.durationSec : 0,
});
} catch {
// best-effort; seekbar falls back to placeholder waveform
}
}
/** When `syncPlayingEngine` is false, only update `cachedLoudnessGainByTrackId` (e.g. queue neighbour) — do not call `audio_update_replay_gain` for the already-playing track. */
async function refreshLoudnessForTrack(
trackId: string,
opts?: { syncPlayingEngine?: boolean },
) {
if (!trackId) return;
const syncEngine = opts?.syncPlayingEngine !== false;
emitNormalizationDebug('refresh:start', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:start', normalizationDbgTrackId: trackId });
try {
const row = await invoke<LoudnessCachePayload | null>('analysis_get_loudness_for_track', {
trackId,
targetLufs: useAuthStore.getState().loudnessTargetLufs,
});
if (!row || !Number.isFinite(row.recommendedGainDb)) {
delete cachedLoudnessGainByTrackId[trackId];
delete stableLoudnessGainByTrackId[trackId];
emitNormalizationDebug('refresh:miss', { trackId, row: row ?? null });
const auth = useAuthStore.getState();
const attempts = analysisBackfillAttemptsByTrackId[trackId] ?? 0;
if (auth.normalizationEngine === 'loudness'
&& !analysisBackfillInFlightByTrackId[trackId]
&& attempts < MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
analysisBackfillInFlightByTrackId[trackId] = true;
analysisBackfillAttemptsByTrackId[trackId] = attempts + 1;
const url = buildStreamUrl(trackId);
emitNormalizationDebug('backfill:enqueue', { trackId, url, attempt: attempts + 1 });
void invoke('analysis_enqueue_seed_from_url', { trackId, url })
.then(() => emitNormalizationDebug('backfill:queued', { trackId, attempt: attempts + 1 }))
.catch((e) => emitNormalizationDebug('backfill:error', { trackId, error: String(e) }))
.finally(() => {
delete analysisBackfillInFlightByTrackId[trackId];
});
} else if (auth.normalizationEngine === 'loudness' && attempts >= MAX_BACKFILL_ATTEMPTS_PER_TRACK) {
emitNormalizationDebug('backfill:throttled', { trackId, attempts });
}
usePlayerStore.setState({
normalizationDbgSource: 'refresh:miss',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: Number.isFinite(row?.targetLufs as number) ? (row?.targetLufs as number) : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row?.updatedAt as number) ? (row?.updatedAt as number) : null,
});
return;
}
cachedLoudnessGainByTrackId[trackId] = row.recommendedGainDb;
stableLoudnessGainByTrackId[trackId] = true;
analysisBackfillAttemptsByTrackId[trackId] = 0;
emitNormalizationDebug('refresh:hit', { trackId, row });
usePlayerStore.setState({
normalizationDbgSource: 'refresh:hit',
normalizationDbgTrackId: trackId,
normalizationDbgCacheGainDb: row.recommendedGainDb,
normalizationDbgCacheTargetLufs: Number.isFinite(row.targetLufs) ? row.targetLufs : null,
normalizationDbgCacheUpdatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : null,
});
if (syncEngine) {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
} catch {
delete cachedLoudnessGainByTrackId[trackId];
delete stableLoudnessGainByTrackId[trackId];
emitNormalizationDebug('refresh:error', { trackId });
usePlayerStore.setState({ normalizationDbgSource: 'refresh:error', normalizationDbgTrackId: trackId });
}
}
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
try {
const res = await invoke<{ path: string; size: number } | null>(
@@ -567,7 +763,19 @@ function handleAudioProgress(current_time: number, duration: number) {
const dur = duration > 0 ? duration : track.duration;
if (dur <= 0) return;
const progress = displayTime / dur;
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
const stateNow = usePlayerStore.getState();
if (stateNow.waveformIsPartial) {
usePlayerStore.setState({
currentTime: displayTime,
progress,
buffered: 0,
// Keep known span aligned with playback position during partial stage:
// if playback moved forward, this part is definitely known already.
waveformKnownUntilSec: Math.max(stateNow.waveformKnownUntilSec || 0, displayTime),
});
} else {
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
}
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
if (progress >= 0.5 && !store.scrobbled) {
@@ -641,6 +849,9 @@ function handleAudioProgress(current_time: number, duration: number) {
// Byte pre-download — runs early so bytes are cached by chain time.
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
bytePreloadingId = nextTrack.id;
// Keep analysis context warm for the upcoming track before any source swap.
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
if (import.meta.env.DEV) {
console.info('[psysonic][preload-request]', {
nextTrackId: nextTrack.id,
@@ -657,6 +868,8 @@ function handleAudioProgress(current_time: number, duration: number) {
// Gapless chain — decode + chain into Sink 30s before track boundary.
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
gaplessPreloadingId = nextTrack.id;
// Ensure loudness gain is already cached for the chained request payload.
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
const authState = useAuthStore.getState();
// Auto-mode neighbours for the *next* track: current track on its left,
// queue[nextIdx+1] on its right.
@@ -665,9 +878,9 @@ function handleAudioProgress(current_time: number, duration: number) {
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
const replayGainDb = resolveReplayGainDb(
nextTrack, track, nextNeighbour,
authState.replayGainEnabled, authState.replayGainMode,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = authState.replayGainEnabled
const replayGainPeak = isReplayGainActive()
? (nextTrack.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
@@ -676,6 +889,7 @@ function handleAudioProgress(current_time: number, duration: number) {
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: cachedLoudnessGainByTrackId[nextTrack.id] ?? null,
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
hiResEnabled: authState.enableHiRes,
@@ -700,7 +914,14 @@ function handleAudioEnded() {
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
usePlayerStore.setState({
isPlaying: false,
progress: 0,
currentTime: 0,
buffered: 0,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
});
setTimeout(() => {
if (repeatMode === 'one' && currentTrack) {
usePlayerStore.getState().playTrack(currentTrack, queue, false);
@@ -744,6 +965,13 @@ function handleAudioTrackSwitched(duration: number) {
usePlayerStore.setState({
currentTrack: nextTrack,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
normalizationDbgSource: 'track-switched',
normalizationDbgTrackId: nextTrack.id,
queueIndex: newIndex,
isPlaying: true,
progress: 0,
@@ -752,6 +980,14 @@ function handleAudioTrackSwitched(duration: number) {
scrobbled: false,
lastfmLoved: false,
});
emitNormalizationDebug('track-switched', {
trackId: nextTrack.id,
queueIndex: newIndex,
engineRequested: useAuthStore.getState().normalizationEngine,
});
void refreshWaveformForTrack(nextTrack.id);
void refreshLoudnessForTrack(nextTrack.id);
usePlayerStore.getState().updateReplayGainForCurrentTrack();
// Report Now Playing to Navidrome + Last.fm
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
@@ -817,6 +1053,90 @@ export function initAudioListeners(): () => void {
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
listen<{ trackId?: string | null; bins: number[]; knownUntilSec: number; durationSec: number; isPartial: boolean }>('analysis:waveform-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (payloadTrackId && payloadTrackId !== current.id) return;
if (!payload.isPartial || !payload?.bins?.length) {
usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 });
return;
}
usePlayerStore.setState({
waveformBins: payload.bins,
waveformIsPartial: true,
waveformKnownUntilSec: Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0,
waveformDurationSec: Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0),
});
}),
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
const current = usePlayerStore.getState().currentTrack;
if (!current || !payload) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (payloadTrackId && payloadTrackId !== current.id) return;
if (!Number.isFinite(payload.gainDb)) return;
if (stableLoudnessGainByTrackId[current.id]) return;
cachedLoudnessGainByTrackId[current.id] = payload.gainDb;
emitNormalizationDebug('partial-loudness:apply', {
trackId: current.id,
gainDb: payload.gainDb,
targetLufs: payload.targetLufs,
});
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}),
listen<{ trackId: string; isPartial: boolean }>('analysis:waveform-updated', ({ payload }) => {
if (!payload?.trackId) return;
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
if (!payloadTrackId) return;
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId && payloadTrackId === currentId) {
void refreshWaveformForTrack(currentId);
void refreshLoudnessForTrack(currentId);
emitNormalizationDebug('backfill:applied', { trackId: currentId });
return;
}
// Backfill finished for another id (e.g. next in queue): refresh loudness cache only
// so `cachedLoudnessGainByTrackId` is ready before `audio_play` / gapless chain.
void refreshLoudnessForTrack(payloadTrackId, { syncPlayingEngine: false });
emitNormalizationDebug('backfill:applied', { trackId: payloadTrackId });
}),
listen<NormalizationStatePayload>('audio:normalization-state', ({ payload }) => {
if (!payload) return;
const engine =
payload.engine === 'loudness' || payload.engine === 'replaygain'
? payload.engine
: 'off';
const nowDb = Number.isFinite(payload.currentGainDb as number) ? (payload.currentGainDb as number) : null;
const targetLufs = Number.isFinite(payload.targetLufs) ? payload.targetLufs : null;
const prev = usePlayerStore.getState();
// Avoid UI flicker from noisy duplicate emits and transient nulls.
if (
engine === prev.normalizationEngineLive
&& normalizationAlmostEqual(nowDb, prev.normalizationNowDb)
&& normalizationAlmostEqual(targetLufs, prev.normalizationTargetLufs, 0.02)
) {
return;
}
if (engine === 'loudness' && nowDb == null && prev.normalizationNowDb != null) {
return;
}
const nowMs = Date.now();
if (nowMs - lastNormalizationUiUpdateAtMs < 120 && engine === prev.normalizationEngineLive) {
return;
}
lastNormalizationUiUpdateAtMs = nowMs;
emitNormalizationDebug('event:audio:normalization-state', {
trackId: usePlayerStore.getState().currentTrack?.id ?? null,
payload,
});
usePlayerStore.setState({
normalizationEngineLive: engine,
normalizationNowDb: nowDb,
normalizationTargetLufs: targetLufs,
normalizationDbgSource: 'event:audio:normalization-state',
normalizationDbgLastEventAt: Date.now(),
});
}),
listen<string>('audio:preload-ready', ({ payload }) => {
const tid = streamUrlTrackId(payload);
if (import.meta.env.DEV) {
@@ -840,17 +1160,83 @@ export function initAudioListeners(): () => void {
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
const normCfg = useAuthStore.getState();
usePlayerStore.setState({
normalizationEngineLive: normCfg.normalizationEngine,
normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null,
normalizationNowDb: null,
normalizationDbgSource: 'init:set-normalization',
});
emitNormalizationDebug('init:set-normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invoke('audio_set_normalization', {
engine: normCfg.normalizationEngine,
targetLufs: normCfg.loudnessTargetLufs,
}).catch(() => {});
if (normCfg.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
}
if (audioOutputDevice) {
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
}
// Keep audio settings in sync whenever auth store changes.
let prevNormEngine = normCfg.normalizationEngine;
let prevNormTarget = normCfg.loudnessTargetLufs;
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs,
}).catch(() => {});
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
const normChanged =
state.normalizationEngine !== prevNormEngine
|| state.loudnessTargetLufs !== prevNormTarget;
if (!normChanged) return;
prevNormEngine = state.normalizationEngine;
prevNormTarget = state.loudnessTargetLufs;
usePlayerStore.setState({
normalizationEngineLive: state.normalizationEngine,
normalizationTargetLufs: state.normalizationEngine === 'loudness' ? state.loudnessTargetLufs : null,
normalizationNowDb: state.normalizationEngine === 'loudness'
? usePlayerStore.getState().normalizationNowDb
: null,
normalizationDbgSource: 'auth:normalization-changed',
});
emitNormalizationDebug('auth:normalization-changed', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
});
invoke('audio_set_normalization', {
engine: state.normalizationEngine,
targetLufs: state.loudnessTargetLufs,
}).catch(() => {});
if (state.normalizationEngine === 'loudness') {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (currentId) {
void refreshLoudnessForTrack(currentId).finally(() => {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
});
}
} else {
usePlayerStore.getState().updateReplayGainForCurrentTrack();
}
});
const unsubAnalysisSync = onAnalysisStorageChanged(detail => {
const currentId = usePlayerStore.getState().currentTrack?.id;
if (!currentId) return;
if (detail.trackId && detail.trackId !== currentId) return;
void refreshWaveformForTrack(currentId);
void refreshLoudnessForTrack(currentId);
});
// ── MPRIS / OS media controls sync ───────────────────────────────────────
@@ -1012,6 +1398,7 @@ export function initAudioListeners(): () => void {
return () => {
unsubAuth();
unsubAnalysisSync();
unsubMpris();
unsubDiscordPlayer();
unsubDiscordAuth();
@@ -1026,6 +1413,19 @@ export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => ({
currentTrack: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
normalizationDbgSource: null,
normalizationDbgTrackId: null,
normalizationDbgCacheGainDb: null,
normalizationDbgCacheTargetLufs: null,
normalizationDbgCacheUpdatedAt: null,
normalizationDbgLastEventAt: null,
currentRadio: null,
currentPlaybackSource: null,
enginePreloadedTrackId: null,
@@ -1152,6 +1552,13 @@ export const usePlayerStore = create<PlayerState>()(
buffered: 0,
currentTime: 0,
currentRadio: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
enginePreloadedTrackId: null,
scheduledPauseAtMs: null,
@@ -1192,6 +1599,13 @@ export const usePlayerStore = create<PlayerState>()(
set({
currentRadio: station,
currentTrack: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
normalizationNowDb: null,
normalizationTargetLufs: null,
normalizationEngineLive: 'off',
currentPlaybackSource: null,
queue: [],
queueIndex: 0,
@@ -1271,6 +1685,11 @@ export const usePlayerStore = create<PlayerState>()(
set({
currentTrack: track,
currentRadio: null,
waveformBins: null,
waveformIsPartial: false,
waveformKnownUntilSec: 0,
waveformDurationSec: 0,
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
@@ -1295,20 +1714,23 @@ export const usePlayerStore = create<PlayerState>()(
authState.hotCacheDownloadDir || null,
);
}
void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id);
setDeferHotCachePrefetch(true);
const playIdx = idx >= 0 ? idx : 0;
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour,
authState.replayGainEnabled, authState.replayGainMode,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: cachedLoudnessGainByTrackId[track.id] ?? null,
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
manual,
@@ -1432,9 +1854,9 @@ export const usePlayerStore = create<PlayerState>()(
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
trackToPlay, coldPrev, coldNext,
authStateCold.replayGainEnabled, authStateCold.replayGainMode,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
@@ -1445,6 +1867,7 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: cachedLoudnessGainByTrackId[trackToPlay.id] ?? null,
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
@@ -1466,9 +1889,9 @@ export const usePlayerStore = create<PlayerState>()(
const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb(
currentTrack, coldPrev, coldNext,
authStateCold.replayGainEnabled, authStateCold.replayGainMode,
isReplayGainActive(), authStateCold.replayGainMode,
);
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
const coldServerId = useAuthStore.getState().activeServerId ?? '';
setDeferHotCachePrefetch(true);
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
@@ -1479,6 +1902,7 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
loudnessGainDb: cachedLoudnessGainByTrackId[currentTrack.id] ?? null,
preGainDb: authStateCold.replayGainPreGainDb,
fallbackDb: authStateCold.replayGainFallbackDb,
manual: false,
@@ -1895,16 +2319,26 @@ export const usePlayerStore = create<PlayerState>()(
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
const replayGainDb = resolveReplayGainDb(
currentTrack, prev, next,
authState.replayGainEnabled, authState.replayGainMode,
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = authState.replayGainEnabled
const replayGainPeak = isReplayGainActive()
? (currentTrack.replayGainPeak ?? null)
: null;
invoke('audio_update_replay_gain', {
const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex);
set(prevState => ({
normalizationNowDb:
normalization.normalizationEngineLive === 'loudness'
? prevState.normalizationNowDb
: normalization.normalizationNowDb,
normalizationTargetLufs: normalization.normalizationTargetLufs,
normalizationEngineLive: normalization.normalizationEngineLive,
}));
invoke('audio_update_replay_gain', {
volume,
replayGainDb,
replayGainPeak,
loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null,
preGainDb: authState.replayGainPreGainDb,
fallbackDb: authState.replayGainFallbackDb,
}).catch(console.error);