mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +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",
|
"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]]
|
[[package]]
|
||||||
name = "dasp_sample"
|
name = "dasp_sample"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -1169,6 +1178,18 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -1302,6 +1323,18 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -1908,6 +1941,15 @@ version = "0.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2548,6 +2590,17 @@ dependencies = [
|
|||||||
"redox_syscall 0.7.4",
|
"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]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@@ -3657,6 +3710,7 @@ version = "1.43.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"biquad",
|
"biquad",
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
|
"ebur128",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"id3",
|
"id3",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3665,6 +3719,7 @@ dependencies = [
|
|||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
"rodio",
|
"rodio",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"souvlaki",
|
"souvlaki",
|
||||||
@@ -4116,6 +4171,20 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -6115,6 +6184,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ lofty = "0.22"
|
|||||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||||
id3 = "1.16.4"
|
id3 = "1.16.4"
|
||||||
symphonia-adapter-libopus = "0.2.7"
|
symphonia-adapter-libopus = "0.2.7"
|
||||||
|
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||||
|
ebur128 = "0.1"
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -1063,10 +1063,11 @@ fn set_hw_params_from_format(
|
|||||||
hw_params.set_buffer_size(v as alsa::pcm::Frames)?;
|
hw_params.set_buffer_size(v as alsa::pcm::Frames)?;
|
||||||
}
|
}
|
||||||
BufferSize::Default => {
|
BufferSize::Default => {
|
||||||
// These values together represent a moderate latency and wakeup interval.
|
// Larger buffer than upstream cpal default: after Symphonia seeks or CPU
|
||||||
// Without them, we are at the mercy of the device
|
// spikes (analysis, demux) the mixer needs more margin before ALSA underruns.
|
||||||
hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?;
|
// ~40 ms period, ~200 ms total buffer (device may round to nearest supported).
|
||||||
hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?;
|
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},
|
units::{self, Time},
|
||||||
};
|
};
|
||||||
use futures_util::StreamExt;
|
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 ────────────────────────────────────────────────
|
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1021,17 +1055,20 @@ async fn track_download_task(
|
|||||||
gen: u64,
|
gen: u64,
|
||||||
gen_arc: Arc<AtomicU64>,
|
gen_arc: Arc<AtomicU64>,
|
||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
|
app: AppHandle,
|
||||||
url: String,
|
url: String,
|
||||||
initial_response: reqwest::Response,
|
initial_response: reqwest::Response,
|
||||||
mut prod: HeapProducer<u8>,
|
mut prod: HeapProducer<u8>,
|
||||||
done: Arc<AtomicBool>,
|
done: Arc<AtomicBool>,
|
||||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||||
|
normalization_target_lufs: Arc<AtomicU32>,
|
||||||
) {
|
) {
|
||||||
let mut downloaded: u64 = 0;
|
let mut downloaded: u64 = 0;
|
||||||
let mut reconnects: u32 = 0;
|
let mut reconnects: u32 = 0;
|
||||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||||
let mut capture: Vec<u8> = Vec::new();
|
let mut capture: Vec<u8> = Vec::new();
|
||||||
let mut capture_over_limit = false;
|
let mut capture_over_limit = false;
|
||||||
|
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
let response = if let Some(r) = next_response.take() {
|
let response = if let Some(r) = next_response.take() {
|
||||||
r
|
r
|
||||||
@@ -1120,12 +1157,29 @@ async fn track_download_task(
|
|||||||
capture_over_limit = true;
|
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;
|
offset += pushed;
|
||||||
downloaded += pushed as u64;
|
downloaded += pushed as u64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !capture_over_limit && !capture.is_empty() {
|
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 {
|
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
data: capture,
|
data: capture,
|
||||||
@@ -1144,12 +1198,15 @@ async fn ranged_download_task(
|
|||||||
gen: u64,
|
gen: u64,
|
||||||
gen_arc: Arc<AtomicU64>,
|
gen_arc: Arc<AtomicU64>,
|
||||||
http_client: reqwest::Client,
|
http_client: reqwest::Client,
|
||||||
|
app: AppHandle,
|
||||||
|
duration_hint: f64,
|
||||||
url: String,
|
url: String,
|
||||||
initial_response: reqwest::Response,
|
initial_response: reqwest::Response,
|
||||||
buf: Arc<Mutex<Vec<u8>>>,
|
buf: Arc<Mutex<Vec<u8>>>,
|
||||||
downloaded_to: Arc<AtomicUsize>,
|
downloaded_to: Arc<AtomicUsize>,
|
||||||
done: Arc<AtomicBool>,
|
done: Arc<AtomicBool>,
|
||||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||||
|
normalization_target_lufs: Arc<AtomicU32>,
|
||||||
) {
|
) {
|
||||||
let total_size = buf.lock().unwrap().len();
|
let total_size = buf.lock().unwrap().len();
|
||||||
let mut downloaded: usize = 0;
|
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 mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||||
let dl_started = Instant::now();
|
let dl_started = Instant::now();
|
||||||
let mut next_progress_mb: usize = 1;
|
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 {
|
'outer: loop {
|
||||||
let response = if let Some(r) = next_response.take() {
|
let response = if let Some(r) = next_response.take() {
|
||||||
@@ -1231,6 +1290,51 @@ async fn ranged_download_task(
|
|||||||
}
|
}
|
||||||
downloaded += n;
|
downloaded += n;
|
||||||
downloaded_to.store(downloaded, Ordering::SeqCst);
|
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);
|
let mb = downloaded / (1024 * 1024);
|
||||||
if mb >= next_progress_mb {
|
if mb >= next_progress_mb {
|
||||||
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
|
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 {
|
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||||
let data = buf.lock().unwrap().clone();
|
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 });
|
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||||
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
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> {
|
fn content_type_to_hint(ct: &str) -> Option<String> {
|
||||||
let ct = ct.to_ascii_lowercase();
|
let ct = ct.to_ascii_lowercase();
|
||||||
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
|
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
|
/// When true, audio_play chains sources to the existing Sink instead of
|
||||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||||
pub gapless_enabled: Arc<AtomicBool>,
|
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).
|
/// Info about the next-up chained track (gapless mode).
|
||||||
/// The progress task reads this when `current_source_done` fires.
|
/// The progress task reads this when `current_source_done` fires.
|
||||||
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
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.
|
/// Active radio session state. None for regular (non-radio) tracks.
|
||||||
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
||||||
pub(crate) radio_state: Mutex<Option<RadioLiveState>>,
|
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 {
|
pub struct AudioCurrent {
|
||||||
@@ -2190,11 +2435,13 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
// Set PipeWire / PulseAudio latency hints before the first open.
|
// Set PipeWire / PulseAudio latency hints before the first open.
|
||||||
#[cfg(target_os = "linux")]
|
#[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() {
|
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() {
|
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())),
|
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
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)),
|
chained_info: Arc::new(Mutex::new(None)),
|
||||||
samples_played: Arc::new(AtomicU64::new(0)),
|
samples_played: Arc::new(AtomicU64::new(0)),
|
||||||
current_sample_rate: Arc::new(AtomicU32::new(0)),
|
current_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||||
current_channels: Arc::new(AtomicU32::new(2)),
|
current_channels: Arc::new(AtomicU32::new(2)),
|
||||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||||
radio_state: Mutex::new(None),
|
radio_state: Mutex::new(None),
|
||||||
|
current_playback_url: Arc::new(Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
(engine, thread)
|
(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`.
|
/// 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>> {
|
pub(crate) fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option<Vec<u8>> {
|
||||||
let mut guard = state.stream_completed_cache.lock().unwrap();
|
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.
|
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
|
||||||
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
|
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
|
||||||
const MASTER_HEADROOM: f32 = 0.891_254;
|
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(
|
fn compute_gain(
|
||||||
|
normalization_engine: u32,
|
||||||
replay_gain_db: Option<f32>,
|
replay_gain_db: Option<f32>,
|
||||||
replay_gain_peak: Option<f32>,
|
replay_gain_peak: Option<f32>,
|
||||||
|
loudness_gain_db: Option<f32>,
|
||||||
pre_gain_db: f32,
|
pre_gain_db: f32,
|
||||||
fallback_db: f32,
|
fallback_db: f32,
|
||||||
volume: f32,
|
volume: f32,
|
||||||
) -> (f32, f32) {
|
) -> (f32, f32) {
|
||||||
let gain_linear = replay_gain_db
|
let gain_linear = match normalization_engine {
|
||||||
.map(|db| 10f32.powf((db + pre_gain_db) / 20.0))
|
2 => loudness_gain_db
|
||||||
.unwrap_or_else(|| 10f32.powf(fallback_db / 20.0));
|
.map(|db| 10f32.powf(db / 20.0))
|
||||||
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
|
.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 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);
|
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||||
(gain_linear, effective)
|
(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 ─────────────────────────────────────────────────────────────────
|
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -2465,6 +2891,7 @@ pub async fn audio_play(
|
|||||||
duration_hint: f64,
|
duration_hint: f64,
|
||||||
replay_gain_db: Option<f32>,
|
replay_gain_db: Option<f32>,
|
||||||
replay_gain_peak: Option<f32>,
|
replay_gain_peak: Option<f32>,
|
||||||
|
loudness_gain_db: Option<f32>,
|
||||||
pre_gain_db: f32,
|
pre_gain_db: f32,
|
||||||
fallback_db: f32,
|
fallback_db: f32,
|
||||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||||
@@ -2541,6 +2968,11 @@ pub async fn audio_play(
|
|||||||
old.stop();
|
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
|
// Extract format hint from URL for better symphonia probing. Strip the
|
||||||
// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||||
// don't latch onto random query-param substrings; only accept short
|
// 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"));
|
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||||
let total_size = response.content_length();
|
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;
|
let total_usize = total as usize;
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
|
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
|
||||||
@@ -2660,12 +3096,15 @@ pub async fn audio_play(
|
|||||||
gen,
|
gen,
|
||||||
state.generation.clone(),
|
state.generation.clone(),
|
||||||
audio_http_client(&state),
|
audio_http_client(&state),
|
||||||
|
app.clone(),
|
||||||
|
duration_hint,
|
||||||
url.clone(),
|
url.clone(),
|
||||||
response,
|
response,
|
||||||
buf.clone(),
|
buf.clone(),
|
||||||
downloaded_to.clone(),
|
downloaded_to.clone(),
|
||||||
done.clone(),
|
done.clone(),
|
||||||
state.stream_completed_cache.clone(),
|
state.stream_completed_cache.clone(),
|
||||||
|
state.normalization_target_lufs.clone(),
|
||||||
));
|
));
|
||||||
let reader = RangedHttpSource {
|
let reader = RangedHttpSource {
|
||||||
buf,
|
buf,
|
||||||
@@ -2683,8 +3122,8 @@ pub async fn audio_play(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::app_deprintln!(
|
crate::app_deprintln!(
|
||||||
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}",
|
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||||
supports_range, total_size
|
supports_range, total_size, stream_hint
|
||||||
);
|
);
|
||||||
let buffer_cap = total_size
|
let buffer_cap = total_size
|
||||||
.map(|n| n as usize)
|
.map(|n| n as usize)
|
||||||
@@ -2697,11 +3136,13 @@ pub async fn audio_play(
|
|||||||
gen,
|
gen,
|
||||||
state.generation.clone(),
|
state.generation.clone(),
|
||||||
audio_http_client(&state),
|
audio_http_client(&state),
|
||||||
|
app.clone(),
|
||||||
url.clone(),
|
url.clone(),
|
||||||
response,
|
response,
|
||||||
prod,
|
prod,
|
||||||
done.clone(),
|
done.clone(),
|
||||||
state.stream_completed_cache.clone(),
|
state.stream_completed_cache.clone(),
|
||||||
|
state.normalization_target_lufs.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
||||||
@@ -2735,7 +3176,45 @@ pub async fn audio_play(
|
|||||||
return Ok(());
|
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.
|
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
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_sample_rate.clone(),
|
||||||
state.current_channels.clone(),
|
state.current_channels.clone(),
|
||||||
state.gapless_switch_at.clone(),
|
state.gapless_switch_at.clone(),
|
||||||
|
state.current_playback_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3020,9 +3500,11 @@ pub async fn audio_chain_preload(
|
|||||||
duration_hint: f64,
|
duration_hint: f64,
|
||||||
replay_gain_db: Option<f32>,
|
replay_gain_db: Option<f32>,
|
||||||
replay_gain_peak: Option<f32>,
|
replay_gain_peak: Option<f32>,
|
||||||
|
loudness_gain_db: Option<f32>,
|
||||||
pre_gain_db: f32,
|
pre_gain_db: f32,
|
||||||
fallback_db: f32,
|
fallback_db: f32,
|
||||||
hi_res_enabled: bool,
|
hi_res_enabled: bool,
|
||||||
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Idempotent: already chained this track → nothing to do.
|
// 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.
|
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
|
||||||
// the still-playing current source). Volume for the chained track is
|
// the still-playing current source). Volume for the chained track is
|
||||||
// applied at the gapless transition in `spawn_progress_task`, not here.
|
// 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));
|
let done_next = Arc::new(AtomicBool::new(false));
|
||||||
// Use a dedicated counter for the chained source — it will be swapped into
|
// 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>,
|
sample_rate_arc: Arc<AtomicU32>,
|
||||||
channels_arc: Arc<AtomicU32>,
|
channels_arc: Arc<AtomicU32>,
|
||||||
gapless_switch_at: Arc<AtomicU64>,
|
gapless_switch_at: Arc<AtomicU64>,
|
||||||
|
current_playback_url: Arc<Mutex<Option<String>>>,
|
||||||
) {
|
) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut near_end_ticks: u32 = 0;
|
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.
|
// must only be called at the boundary, not at preload.
|
||||||
{
|
{
|
||||||
let mut cur = current_arc.lock().unwrap();
|
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.replay_gain_linear = info.replay_gain_linear;
|
||||||
cur.base_volume = info.base_volume;
|
cur.base_volume = info.base_volume;
|
||||||
cur.duration_secs = info.duration_secs;
|
cur.duration_secs = info.duration_secs;
|
||||||
@@ -3246,10 +3745,12 @@ fn spawn_progress_task(
|
|||||||
cur.play_started = Some(Instant::now());
|
cur.play_started = Some(Instant::now());
|
||||||
if let Some(sink) = &cur.sink {
|
if let Some(sink) = &cur.sink {
|
||||||
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
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.
|
// Record the gapless switch timestamp for ghost-command guard.
|
||||||
let switch_ts = std::time::SystemTime::now()
|
let switch_ts = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -3411,6 +3912,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||||
|
*state.current_playback_url.lock().unwrap() = None;
|
||||||
*state.chained_info.lock().unwrap() = None;
|
*state.chained_info.lock().unwrap() = None;
|
||||||
*state.stream_completed_cache.lock().unwrap() = None;
|
*state.stream_completed_cache.lock().unwrap() = None;
|
||||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
// 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]
|
#[tauri::command]
|
||||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||||
let mut cur = state.current.lock().unwrap();
|
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);
|
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||||
if let Some(sink) = &cur.sink {
|
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,
|
volume: f32,
|
||||||
replay_gain_db: Option<f32>,
|
replay_gain_db: Option<f32>,
|
||||||
replay_gain_peak: Option<f32>,
|
replay_gain_peak: Option<f32>,
|
||||||
|
loudness_gain_db: Option<f32>,
|
||||||
pre_gain_db: f32,
|
pre_gain_db: f32,
|
||||||
fallback_db: f32,
|
fallback_db: f32,
|
||||||
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
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 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.replay_gain_linear = gain_linear;
|
||||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||||
if let Some(sink) = &cur.sink {
|
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.
|
/// 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()
|
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||||
};
|
};
|
||||||
let _ = duration_hint; // kept in API for compatibility
|
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();
|
let url_for_emit = url.clone();
|
||||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||||
let _ = app.emit("audio:preload-ready", url_for_emit);
|
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);
|
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_sample_rate.store(sample_rate, Ordering::Relaxed);
|
||||||
state.current_channels.store(channels as u32, 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_sample_rate.clone(),
|
||||||
state.current_channels.clone(),
|
state.current_channels.clone(),
|
||||||
state.gapless_switch_at.clone(),
|
state.gapless_switch_at.clone(),
|
||||||
|
state.current_playback_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -3980,6 +4547,36 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
|||||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
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 ────────────────────────────────────────────────────
|
// ─── Device-change watcher ────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Polls every 3 s for two conditions:
|
// Polls every 3 s for two conditions:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod analysis_cache;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
mod discord;
|
mod discord;
|
||||||
pub(crate) mod logging;
|
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()))
|
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.
|
/// 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.
|
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
|
||||||
type TrayState = Mutex<Option<TrayIcon>>;
|
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).
|
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
|
||||||
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
|
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]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
format!("Hello, {}!", name)
|
format!("Hello, {}!", name)
|
||||||
@@ -1131,6 +1166,126 @@ fn check_dir_accessible(path: String) -> bool {
|
|||||||
std::path::Path::new(&path).is_dir()
|
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 ──────────────────────────────────────────────────────
|
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Streams an HTTP response body directly to `dest_path` in small chunks.
|
/// 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(())
|
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.
|
/// Downloads a single track to the app's offline cache directory.
|
||||||
/// Returns the absolute file path so TypeScript can store it and later
|
/// Returns the absolute file path so TypeScript can store it and later
|
||||||
/// construct a `psysonic-local://<path>` URL for the audio engine.
|
/// construct a `psysonic-local://<path>` URL for the audio engine.
|
||||||
@@ -1217,6 +1413,8 @@ async fn download_track_offline(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
|
||||||
|
|
||||||
Ok(path_str)
|
Ok(path_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1765,6 +1963,8 @@ async fn download_track_hot_cache(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
|
||||||
|
|
||||||
let size = tokio::fs::metadata(&file_path)
|
let size = tokio::fs::metadata(&file_path)
|
||||||
.await
|
.await
|
||||||
.map(|m| m.len())
|
.map(|m| m.len())
|
||||||
@@ -1818,6 +2018,8 @@ async fn promote_stream_cache_to_hot_cache(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let _ = enqueue_analysis_seed(&app, &track_id, &bytes);
|
||||||
|
|
||||||
let size = tokio::fs::metadata(&file_path)
|
let size = tokio::fs::metadata(&file_path)
|
||||||
.await
|
.await
|
||||||
.map(|m| m.len())
|
.map(|m| m.len())
|
||||||
@@ -3582,6 +3784,13 @@ pub fn run() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
.setup(|app| {
|
.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 ─────────────────────────────────
|
// ── Custom title bar on Linux ─────────────────────────────────
|
||||||
// Remove OS window decorations on all Linux so the React TitleBar
|
// Remove OS window decorations on all Linux so the React TitleBar
|
||||||
// can take over. The frontend checks is_tiling_wm() to decide
|
// can take over. The frontend checks is_tiling_wm() to decide
|
||||||
@@ -3820,6 +4029,7 @@ pub fn run() {
|
|||||||
audio::audio_play_radio,
|
audio::audio_play_radio,
|
||||||
audio::audio_set_crossfade,
|
audio::audio_set_crossfade,
|
||||||
audio::audio_set_gapless,
|
audio::audio_set_gapless,
|
||||||
|
audio::audio_set_normalization,
|
||||||
audio::audio_list_devices,
|
audio::audio_list_devices,
|
||||||
audio::audio_canonicalize_selected_device,
|
audio::audio_canonicalize_selected_device,
|
||||||
audio::audio_default_output_device_name,
|
audio::audio_default_output_device_name,
|
||||||
@@ -3850,6 +4060,10 @@ pub fn run() {
|
|||||||
fetch_json_url,
|
fetch_json_url,
|
||||||
fetch_icy_metadata,
|
fetch_icy_metadata,
|
||||||
resolve_stream_url,
|
resolve_stream_url,
|
||||||
|
analysis_get_waveform,
|
||||||
|
analysis_get_waveform_for_track,
|
||||||
|
analysis_get_loudness_for_track,
|
||||||
|
analysis_enqueue_seed_from_url,
|
||||||
download_track_offline,
|
download_track_offline,
|
||||||
delete_offline_track,
|
delete_offline_track,
|
||||||
get_offline_cache_size,
|
get_offline_cache_size,
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ export default function QueuePanel() {
|
|||||||
const contextMenu = usePlayerStore(s => s.contextMenu);
|
const contextMenu = usePlayerStore(s => s.contextMenu);
|
||||||
|
|
||||||
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
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 crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||||
@@ -267,6 +270,8 @@ export default function QueuePanel() {
|
|||||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
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 activeTab = useLyricsStore(s => s.activeTab);
|
||||||
const setTab = useLyricsStore(s => s.setTab);
|
const setTab = useLyricsStore(s => s.setTab);
|
||||||
@@ -478,6 +483,14 @@ export default function QueuePanel() {
|
|||||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||||
const baseLine = baseParts.join(' · ');
|
const baseLine = baseParts.join(' · ');
|
||||||
const rgLine = rgParts.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;
|
if (!baseLine && !rgLine && !playbackSource) return null;
|
||||||
const showRgLine = expandReplayGain && !!rgLine;
|
const showRgLine = expandReplayGain && !!rgLine;
|
||||||
return (
|
return (
|
||||||
@@ -522,6 +535,15 @@ export default function QueuePanel() {
|
|||||||
{' · '}{rgLine}
|
{' · '}{rgLine}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -824,6 +824,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
|
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const heightsRef = useRef<Float32Array | null>(null);
|
const heightsRef = useRef<Float32Array | null>(null);
|
||||||
|
const lastHeightsTrackIdRef = useRef<string | undefined>(undefined);
|
||||||
const progressRef = useRef(usePlayerStore.getState().progress);
|
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||||
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
@@ -832,6 +833,10 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||||
|
|
||||||
const seek = usePlayerStore(s => s.seek);
|
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 duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||||
|
|
||||||
@@ -843,10 +848,44 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!trackId) {
|
if (!trackId) {
|
||||||
heightsRef.current = null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
heightsRef.current = makeHeights(trackId);
|
heightsRef.current = makeHeights(trackId);
|
||||||
}, [trackId]);
|
lastHeightsTrackIdRef.current = trackId;
|
||||||
|
}, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]);
|
||||||
|
|
||||||
// Imperative subscription — no React re-renders from progress changes.
|
// Imperative subscription — no React re-renders from progress changes.
|
||||||
// Static styles draw here; animated styles only update refs.
|
// 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 style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
|
<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" />
|
<span className="toggle-track" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{auth.replayGainEnabled && (
|
{auth.replayGainEnabled && (
|
||||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
<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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
||||||
<button
|
<button
|
||||||
@@ -2254,14 +2313,15 @@ export default function Settings() {
|
|||||||
{t('settings.replayGainAlbum')}
|
{t('settings.replayGainAlbum')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{auth.replayGainMode === 'auto' && (
|
)}
|
||||||
|
{auth.normalizationEngine === 'replaygain' && auth.replayGainMode === 'auto' && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
{t('settings.replayGainAutoDesc')}
|
{t('settings.replayGainAutoDesc')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
<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 SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||||
export type LoggingMode = 'off' | 'normal' | 'debug';
|
export type LoggingMode = 'off' | 'normal' | 'debug';
|
||||||
|
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
|
||||||
|
|
||||||
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
|
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
|
||||||
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
||||||
@@ -49,6 +50,8 @@ interface AuthState {
|
|||||||
excludeAudiobooks: boolean;
|
excludeAudiobooks: boolean;
|
||||||
customGenreBlacklist: string[];
|
customGenreBlacklist: string[];
|
||||||
replayGainEnabled: boolean;
|
replayGainEnabled: boolean;
|
||||||
|
normalizationEngine: NormalizationEngine;
|
||||||
|
loudnessTargetLufs: -16 | -14 | -12;
|
||||||
replayGainMode: 'track' | 'album' | 'auto';
|
replayGainMode: 'track' | 'album' | 'auto';
|
||||||
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
|
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
|
||||||
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
|
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
|
||||||
@@ -205,6 +208,8 @@ interface AuthState {
|
|||||||
setExcludeAudiobooks: (v: boolean) => void;
|
setExcludeAudiobooks: (v: boolean) => void;
|
||||||
setCustomGenreBlacklist: (v: string[]) => void;
|
setCustomGenreBlacklist: (v: string[]) => void;
|
||||||
setReplayGainEnabled: (v: boolean) => void;
|
setReplayGainEnabled: (v: boolean) => void;
|
||||||
|
setNormalizationEngine: (v: NormalizationEngine) => void;
|
||||||
|
setLoudnessTargetLufs: (v: -16 | -14 | -12) => void;
|
||||||
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
|
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
|
||||||
setReplayGainPreGainDb: (v: number) => void;
|
setReplayGainPreGainDb: (v: number) => void;
|
||||||
setReplayGainFallbackDb: (v: number) => void;
|
setReplayGainFallbackDb: (v: number) => void;
|
||||||
@@ -315,6 +320,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
excludeAudiobooks: false,
|
excludeAudiobooks: false,
|
||||||
customGenreBlacklist: [],
|
customGenreBlacklist: [],
|
||||||
replayGainEnabled: false,
|
replayGainEnabled: false,
|
||||||
|
normalizationEngine: 'off',
|
||||||
|
loudnessTargetLufs: -14,
|
||||||
replayGainMode: 'auto',
|
replayGainMode: 'auto',
|
||||||
replayGainPreGainDb: 0,
|
replayGainPreGainDb: 0,
|
||||||
replayGainFallbackDb: 0,
|
replayGainFallbackDb: 0,
|
||||||
@@ -442,6 +449,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
set({ replayGainEnabled: v });
|
set({ replayGainEnabled: v });
|
||||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||||
},
|
},
|
||||||
|
setNormalizationEngine: (v) => {
|
||||||
|
set({ normalizationEngine: v });
|
||||||
|
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||||
|
},
|
||||||
|
setLoudnessTargetLufs: (v) => {
|
||||||
|
set({ loudnessTargetLufs: v });
|
||||||
|
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||||
|
},
|
||||||
setReplayGainMode: (v) => {
|
setReplayGainMode: (v) => {
|
||||||
set({ replayGainMode: v });
|
set({ replayGainMode: v });
|
||||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
|
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
|
||||||
import type { Track } from './playerStore';
|
import type { Track } from './playerStore';
|
||||||
|
import { emitAnalysisStorageChanged } from './analysisSync';
|
||||||
|
|
||||||
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
|
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
|
||||||
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
|
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
|
||||||
@@ -96,6 +97,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
|||||||
delete next[entryKey(serverId, trackId)];
|
delete next[entryKey(serverId, trackId)];
|
||||||
return { entries: next };
|
return { entries: next };
|
||||||
});
|
});
|
||||||
|
emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' });
|
||||||
},
|
},
|
||||||
|
|
||||||
totalBytes: () =>
|
totalBytes: () =>
|
||||||
@@ -167,6 +169,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
sum -= meta.sizeBytes || 0;
|
sum -= meta.sizeBytes || 0;
|
||||||
delete entries[key];
|
delete entries[key];
|
||||||
|
emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ entries });
|
set({ entries });
|
||||||
@@ -175,6 +178,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
|||||||
clearAllDisk: async (customDir: string | null) => {
|
clearAllDisk: async (customDir: string | null) => {
|
||||||
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
|
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
|
||||||
set({ entries: {} });
|
set({ entries: {} });
|
||||||
|
emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { SubsonicSong } from '../api/subsonic';
|
|||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
|
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
|
||||||
|
import { emitAnalysisStorageChanged } from './analysisSync';
|
||||||
|
|
||||||
export interface OfflineTrackMeta {
|
export interface OfflineTrackMeta {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -299,6 +300,9 @@ export const useOfflineStore = create<OfflineState>()(
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
for (const trackId of album.trackIds) {
|
||||||
|
emitAnalysisStorageChanged({ trackId, reason: 'offline-delete' });
|
||||||
|
}
|
||||||
|
|
||||||
set(state => {
|
set(state => {
|
||||||
const tracks = { ...state.tracks };
|
const tracks = { ...state.tracks };
|
||||||
|
|||||||
+447
-13
@@ -10,6 +10,7 @@ import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTr
|
|||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { useOfflineStore } from './offlineStore';
|
import { useOfflineStore } from './offlineStore';
|
||||||
import { useHotCacheStore } from './hotCacheStore';
|
import { useHotCacheStore } from './hotCacheStore';
|
||||||
|
import { onAnalysisStorageChanged } from './analysisSync';
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -143,6 +144,19 @@ async function buildInfiniteQueueCandidates(
|
|||||||
|
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
currentTrack: Track | null;
|
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;
|
currentRadio: InternetRadioStation | null;
|
||||||
/** Latches the source used to start the currently playing track. */
|
/** Latches the source used to start the currently playing track. */
|
||||||
currentPlaybackSource: PlaybackSourceKind | null;
|
currentPlaybackSource: PlaybackSourceKind | null;
|
||||||
@@ -241,6 +255,29 @@ interface PlayerState {
|
|||||||
closeSongInfo: () => void;
|
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 ─────────────────────────────────────────
|
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||||
|
|
||||||
// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track.
|
// 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
|
// Artist ID used to start the current radio session — persists across track
|
||||||
// advances so proactive loading works even when songs lack artistId.
|
// advances so proactive loading works even when songs lack artistId.
|
||||||
let currentRadioArtistId: string | null = null;
|
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.
|
// Debounce timer for seek slider drags.
|
||||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -267,6 +364,9 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
let seekTarget: number | null = null;
|
let seekTarget: number | null = null;
|
||||||
let seekTargetSetAt = 0;
|
let seekTargetSetAt = 0;
|
||||||
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
|
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.
|
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||||
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let seekFallbackRetryStartedAt = 0;
|
let seekFallbackRetryStartedAt = 0;
|
||||||
@@ -481,6 +581,102 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
|||||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
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) {
|
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
|
||||||
try {
|
try {
|
||||||
const res = await invoke<{ path: string; size: number } | null>(
|
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;
|
const dur = duration > 0 ? duration : track.duration;
|
||||||
if (dur <= 0) return;
|
if (dur <= 0) return;
|
||||||
const progress = displayTime / dur;
|
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)
|
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||||
if (progress >= 0.5 && !store.scrobbled) {
|
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.
|
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||||
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
||||||
bytePreloadingId = nextTrack.id;
|
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) {
|
if (import.meta.env.DEV) {
|
||||||
console.info('[psysonic][preload-request]', {
|
console.info('[psysonic][preload-request]', {
|
||||||
nextTrackId: nextTrack.id,
|
nextTrackId: nextTrack.id,
|
||||||
@@ -657,6 +868,8 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||||
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
|
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
|
||||||
gaplessPreloadingId = nextTrack.id;
|
gaplessPreloadingId = nextTrack.id;
|
||||||
|
// Ensure loudness gain is already cached for the chained request payload.
|
||||||
|
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
// Auto-mode neighbours for the *next* track: current track on its left,
|
// Auto-mode neighbours for the *next* track: current track on its left,
|
||||||
// queue[nextIdx+1] on its right.
|
// 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);
|
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const replayGainDb = resolveReplayGainDb(
|
||||||
nextTrack, track, nextNeighbour,
|
nextTrack, track, nextNeighbour,
|
||||||
authState.replayGainEnabled, authState.replayGainMode,
|
isReplayGainActive(), authState.replayGainMode,
|
||||||
);
|
);
|
||||||
const replayGainPeak = authState.replayGainEnabled
|
const replayGainPeak = isReplayGainActive()
|
||||||
? (nextTrack.replayGainPeak ?? null)
|
? (nextTrack.replayGainPeak ?? null)
|
||||||
: null;
|
: null;
|
||||||
invoke('audio_chain_preload', {
|
invoke('audio_chain_preload', {
|
||||||
@@ -676,6 +889,7 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
durationHint: nextTrack.duration,
|
durationHint: nextTrack.duration,
|
||||||
replayGainDb,
|
replayGainDb,
|
||||||
replayGainPeak,
|
replayGainPeak,
|
||||||
|
loudnessGainDb: cachedLoudnessGainByTrackId[nextTrack.id] ?? null,
|
||||||
preGainDb: authState.replayGainPreGainDb,
|
preGainDb: authState.replayGainPreGainDb,
|
||||||
fallbackDb: authState.replayGainFallbackDb,
|
fallbackDb: authState.replayGainFallbackDb,
|
||||||
hiResEnabled: authState.enableHiRes,
|
hiResEnabled: authState.enableHiRes,
|
||||||
@@ -700,7 +914,14 @@ function handleAudioEnded() {
|
|||||||
|
|
||||||
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||||
isAudioPaused = false;
|
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(() => {
|
setTimeout(() => {
|
||||||
if (repeatMode === 'one' && currentTrack) {
|
if (repeatMode === 'one' && currentTrack) {
|
||||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||||
@@ -744,6 +965,13 @@ function handleAudioTrackSwitched(duration: number) {
|
|||||||
|
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
currentTrack: nextTrack,
|
currentTrack: nextTrack,
|
||||||
|
waveformBins: null,
|
||||||
|
waveformIsPartial: false,
|
||||||
|
waveformKnownUntilSec: 0,
|
||||||
|
waveformDurationSec: 0,
|
||||||
|
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
|
||||||
|
normalizationDbgSource: 'track-switched',
|
||||||
|
normalizationDbgTrackId: nextTrack.id,
|
||||||
queueIndex: newIndex,
|
queueIndex: newIndex,
|
||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -752,6 +980,14 @@ function handleAudioTrackSwitched(duration: number) {
|
|||||||
scrobbled: false,
|
scrobbled: false,
|
||||||
lastfmLoved: 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
|
// Report Now Playing to Navidrome + Last.fm
|
||||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||||
@@ -817,6 +1053,90 @@ export function initAudioListeners(): () => void {
|
|||||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(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 }) => {
|
listen<string>('audio:preload-ready', ({ payload }) => {
|
||||||
const tid = streamUrlTrackId(payload);
|
const tid = streamUrlTrackId(payload);
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
@@ -840,17 +1160,83 @@ export function initAudioListeners(): () => void {
|
|||||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
|
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
|
||||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).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) {
|
if (audioOutputDevice) {
|
||||||
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
|
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep audio settings in sync whenever auth store changes.
|
// Keep audio settings in sync whenever auth store changes.
|
||||||
|
let prevNormEngine = normCfg.normalizationEngine;
|
||||||
|
let prevNormTarget = normCfg.loudnessTargetLufs;
|
||||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||||
invoke('audio_set_crossfade', {
|
invoke('audio_set_crossfade', {
|
||||||
enabled: state.crossfadeEnabled,
|
enabled: state.crossfadeEnabled,
|
||||||
secs: state.crossfadeSecs,
|
secs: state.crossfadeSecs,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).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 ───────────────────────────────────────
|
// ── MPRIS / OS media controls sync ───────────────────────────────────────
|
||||||
@@ -1012,6 +1398,7 @@ export function initAudioListeners(): () => void {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubAuth();
|
unsubAuth();
|
||||||
|
unsubAnalysisSync();
|
||||||
unsubMpris();
|
unsubMpris();
|
||||||
unsubDiscordPlayer();
|
unsubDiscordPlayer();
|
||||||
unsubDiscordAuth();
|
unsubDiscordAuth();
|
||||||
@@ -1026,6 +1413,19 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
currentTrack: null,
|
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,
|
currentRadio: null,
|
||||||
currentPlaybackSource: null,
|
currentPlaybackSource: null,
|
||||||
enginePreloadedTrackId: null,
|
enginePreloadedTrackId: null,
|
||||||
@@ -1152,6 +1552,13 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
buffered: 0,
|
buffered: 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
currentRadio: null,
|
currentRadio: null,
|
||||||
|
waveformBins: null,
|
||||||
|
waveformIsPartial: false,
|
||||||
|
waveformKnownUntilSec: 0,
|
||||||
|
waveformDurationSec: 0,
|
||||||
|
normalizationNowDb: null,
|
||||||
|
normalizationTargetLufs: null,
|
||||||
|
normalizationEngineLive: 'off',
|
||||||
currentPlaybackSource: null,
|
currentPlaybackSource: null,
|
||||||
enginePreloadedTrackId: null,
|
enginePreloadedTrackId: null,
|
||||||
scheduledPauseAtMs: null,
|
scheduledPauseAtMs: null,
|
||||||
@@ -1192,6 +1599,13 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
set({
|
set({
|
||||||
currentRadio: station,
|
currentRadio: station,
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
|
waveformBins: null,
|
||||||
|
waveformIsPartial: false,
|
||||||
|
waveformKnownUntilSec: 0,
|
||||||
|
waveformDurationSec: 0,
|
||||||
|
normalizationNowDb: null,
|
||||||
|
normalizationTargetLufs: null,
|
||||||
|
normalizationEngineLive: 'off',
|
||||||
currentPlaybackSource: null,
|
currentPlaybackSource: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
@@ -1271,6 +1685,11 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
set({
|
set({
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
currentRadio: null,
|
currentRadio: null,
|
||||||
|
waveformBins: null,
|
||||||
|
waveformIsPartial: false,
|
||||||
|
waveformKnownUntilSec: 0,
|
||||||
|
waveformDurationSec: 0,
|
||||||
|
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
||||||
queue: newQueue,
|
queue: newQueue,
|
||||||
queueIndex: idx >= 0 ? idx : 0,
|
queueIndex: idx >= 0 ? idx : 0,
|
||||||
progress: initialProgress,
|
progress: initialProgress,
|
||||||
@@ -1295,20 +1714,23 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
authState.hotCacheDownloadDir || null,
|
authState.hotCacheDownloadDir || null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
void refreshWaveformForTrack(track.id);
|
||||||
|
void refreshLoudnessForTrack(track.id);
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const playIdx = idx >= 0 ? idx : 0;
|
const playIdx = idx >= 0 ? idx : 0;
|
||||||
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const replayGainDb = resolveReplayGainDb(
|
||||||
track, prevTrack, nextNeighbour,
|
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', {
|
invoke('audio_play', {
|
||||||
url,
|
url,
|
||||||
volume: state.volume,
|
volume: state.volume,
|
||||||
durationHint: track.duration,
|
durationHint: track.duration,
|
||||||
replayGainDb,
|
replayGainDb,
|
||||||
replayGainPeak,
|
replayGainPeak,
|
||||||
|
loudnessGainDb: cachedLoudnessGainByTrackId[track.id] ?? null,
|
||||||
preGainDb: authState.replayGainPreGainDb,
|
preGainDb: authState.replayGainPreGainDb,
|
||||||
fallbackDb: authState.replayGainFallbackDb,
|
fallbackDb: authState.replayGainFallbackDb,
|
||||||
manual,
|
manual,
|
||||||
@@ -1432,9 +1854,9 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const authStateCold = useAuthStore.getState();
|
const authStateCold = useAuthStore.getState();
|
||||||
const replayGainDbCold = resolveReplayGainDb(
|
const replayGainDbCold = resolveReplayGainDb(
|
||||||
trackToPlay, coldPrev, coldNext,
|
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 ?? '';
|
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||||
@@ -1445,6 +1867,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
durationHint: trackToPlay.duration,
|
durationHint: trackToPlay.duration,
|
||||||
replayGainDb: replayGainDbCold,
|
replayGainDb: replayGainDbCold,
|
||||||
replayGainPeak: replayGainPeakCold,
|
replayGainPeak: replayGainPeakCold,
|
||||||
|
loudnessGainDb: cachedLoudnessGainByTrackId[trackToPlay.id] ?? null,
|
||||||
preGainDb: authStateCold.replayGainPreGainDb,
|
preGainDb: authStateCold.replayGainPreGainDb,
|
||||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||||
manual: false,
|
manual: false,
|
||||||
@@ -1466,9 +1889,9 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const authStateCold = useAuthStore.getState();
|
const authStateCold = useAuthStore.getState();
|
||||||
const replayGainDbCold = resolveReplayGainDb(
|
const replayGainDbCold = resolveReplayGainDb(
|
||||||
currentTrack, coldPrev, coldNext,
|
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 ?? '';
|
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
||||||
@@ -1479,6 +1902,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
durationHint: currentTrack.duration,
|
durationHint: currentTrack.duration,
|
||||||
replayGainDb: replayGainDbCold,
|
replayGainDb: replayGainDbCold,
|
||||||
replayGainPeak: replayGainPeakCold,
|
replayGainPeak: replayGainPeakCold,
|
||||||
|
loudnessGainDb: cachedLoudnessGainByTrackId[currentTrack.id] ?? null,
|
||||||
preGainDb: authStateCold.replayGainPreGainDb,
|
preGainDb: authStateCold.replayGainPreGainDb,
|
||||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||||
manual: false,
|
manual: false,
|
||||||
@@ -1895,16 +2319,26 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const replayGainDb = resolveReplayGainDb(
|
||||||
currentTrack, prev, next,
|
currentTrack, prev, next,
|
||||||
authState.replayGainEnabled, authState.replayGainMode,
|
isReplayGainActive(), authState.replayGainMode,
|
||||||
);
|
);
|
||||||
const replayGainPeak = authState.replayGainEnabled
|
const replayGainPeak = isReplayGainActive()
|
||||||
? (currentTrack.replayGainPeak ?? null)
|
? (currentTrack.replayGainPeak ?? null)
|
||||||
: 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,
|
volume,
|
||||||
replayGainDb,
|
replayGainDb,
|
||||||
replayGainPeak,
|
replayGainPeak,
|
||||||
|
loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null,
|
||||||
preGainDb: authState.replayGainPreGainDb,
|
preGainDb: authState.replayGainPreGainDb,
|
||||||
fallbackDb: authState.replayGainFallbackDb,
|
fallbackDb: authState.replayGainFallbackDb,
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|||||||
Reference in New Issue
Block a user