mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
Generated
+75
@@ -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"
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user