mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
Merge pull request #315 from Psychotoxical/feat/waveform-loudness-cache
feat(playback): waveform loudness cache (EBU R128 + persistent analysis)
This commit is contained in:
Generated
+75
@@ -944,6 +944,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_frame"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
@@ -1169,6 +1178,18 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ebur128"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e227cc62d64d6fe01abbef48134b9c1f17d470cef1e7a56337ad05b1f81df7f9"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"dasp_frame",
|
||||
"dasp_sample",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1302,6 +1323,18 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.9.0"
|
||||
@@ -1908,6 +1941,15 @@ version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2548,6 +2590,17 @@ dependencies = [
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.3.8"
|
||||
@@ -3657,6 +3710,7 @@ version = "1.43.0"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
"id3",
|
||||
"libc",
|
||||
@@ -3665,6 +3719,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"ringbuf",
|
||||
"rodio",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
@@ -4116,6 +4171,20 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -6115,6 +6184,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -49,6 +49,8 @@ lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
symphonia-adapter-libopus = "0.2.7"
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
ebur128 = "0.1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -1063,10 +1063,11 @@ fn set_hw_params_from_format(
|
||||
hw_params.set_buffer_size(v as alsa::pcm::Frames)?;
|
||||
}
|
||||
BufferSize::Default => {
|
||||
// These values together represent a moderate latency and wakeup interval.
|
||||
// Without them, we are at the mercy of the device
|
||||
hw_params.set_period_time_near(25_000, alsa::ValueOr::Nearest)?;
|
||||
hw_params.set_buffer_time_near(100_000, alsa::ValueOr::Nearest)?;
|
||||
// Larger buffer than upstream cpal default: after Symphonia seeks or CPU
|
||||
// spikes (analysis, demux) the mixer needs more margin before ALSA underruns.
|
||||
// ~40 ms period, ~200 ms total buffer (device may round to nearest supported).
|
||||
hw_params.set_period_time_near(40_000, alsa::ValueOr::Nearest)?;
|
||||
hw_params.set_buffer_time_near(200_000, alsa::ValueOr::Nearest)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
use std::path::PathBuf;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Instant, 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 = 3;
|
||||
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) })
|
||||
}
|
||||
|
||||
/// Remove all `loudness_cache` rows for this logical track (bare id and `stream:` variant).
|
||||
pub fn delete_loudness_for_track_id(&self, track_id: &str) -> Result<u64, String> {
|
||||
if track_id.trim().is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let mut total: u64 = 0;
|
||||
for tid in track_id_cache_variants(track_id) {
|
||||
let n = conn
|
||||
.execute("DELETE FROM loudness_cache WHERE track_id = ?1", params![tid])
|
||||
.map_err(|e| e.to_string())?;
|
||||
total = total.saturating_add(n as u64);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Remove all cached waveform rows across all tracks/variants.
|
||||
pub fn delete_all_waveforms(&self) -> Result<u64, String> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
||||
let n = conn
|
||||
.execute("DELETE FROM waveform_cache", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Result of [`seed_from_bytes`]: callers use it to avoid redundant UI events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SeedFromBytesOutcome {
|
||||
/// Wrote waveform (and loudness when PCM decode succeeded).
|
||||
Upserted,
|
||||
/// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version.
|
||||
SkippedWaveformCacheHit,
|
||||
/// `AnalysisCache` was not registered on the app handle.
|
||||
SkippedNoAnalysisCache,
|
||||
}
|
||||
|
||||
pub fn seed_from_bytes(
|
||||
app: &tauri::AppHandle,
|
||||
track_id: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<SeedFromBytesOutcome, String> {
|
||||
let started = Instant::now();
|
||||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
|
||||
track_id,
|
||||
bytes.len()
|
||||
);
|
||||
return Ok(SeedFromBytesOutcome::SkippedNoAnalysisCache);
|
||||
};
|
||||
let key = TrackKey {
|
||||
track_id: track_id.to_string(),
|
||||
md5_16kb: md5_first_16kb(bytes),
|
||||
};
|
||||
if let Some(existing) = cache.get_waveform(&key)? {
|
||||
if !existing.bins.is_empty() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
|
||||
track_id,
|
||||
key.md5_16kb,
|
||||
existing.bins.len(),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
return Ok(SeedFromBytesOutcome::SkippedWaveformCacheHit);
|
||||
}
|
||||
}
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build start track_id={} bytes={} md5_16kb={}",
|
||||
track_id,
|
||||
bytes.len(),
|
||||
key.md5_16kb
|
||||
);
|
||||
|
||||
let build = (|| -> Result<(bool, usize), String> {
|
||||
cache.touch_track_status(&key, "queued")?;
|
||||
|
||||
let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) {
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
|
||||
(
|
||||
bins,
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
None => (derive_waveform_bins(bytes, 500), None, false),
|
||||
};
|
||||
let bins_len = wf_bins.len();
|
||||
let waveform = WaveformEntry {
|
||||
bins: wf_bins,
|
||||
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)) = loudness_opt {
|
||||
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((used_pcm_decode, bins_len))
|
||||
})();
|
||||
|
||||
let elapsed_ms = started.elapsed().as_millis();
|
||||
match &build {
|
||||
Ok((used_pcm_decode, bins_len)) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build done track_id={} elapsed_ms={} path={} bins_len={}",
|
||||
track_id,
|
||||
elapsed_ms,
|
||||
if *used_pcm_decode {
|
||||
"pcm_ebur128"
|
||||
} else {
|
||||
"byte_envelope"
|
||||
},
|
||||
bins_len
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build failed track_id={} elapsed_ms={} err={}",
|
||||
track_id,
|
||||
elapsed_ms,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match build {
|
||||
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
struct PcmScanResult {
|
||||
bins: Vec<u8>,
|
||||
loudness: Option<(f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count.
|
||||
fn analyze_loudness_and_waveform(
|
||||
bytes: &[u8],
|
||||
target_lufs: f64,
|
||||
bin_count: usize,
|
||||
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
|
||||
if bytes.is_empty() || bin_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
|
||||
if decoded_frames == 0 {
|
||||
return None;
|
||||
}
|
||||
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
|
||||
let (i, t, r, tgt) = scanned.loudness?;
|
||||
Some((i, t, r, tgt, scanned.bins))
|
||||
}
|
||||
|
||||
/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is
|
||||
/// `codec_params.n_frames` when the container reports total track length — used
|
||||
/// as a **fixed** waveform time axis so partial decodes do not remap every bin
|
||||
/// when the buffer grows.
|
||||
fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option<u64>)> {
|
||||
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 timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
|
||||
let codec_params = track.codec_params.clone();
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&codec_params, &DecoderOptions::default())
|
||||
.ok()?;
|
||||
|
||||
let mut total: u64 = 0;
|
||||
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();
|
||||
let n_ch = spec.channels.count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(decoded);
|
||||
let n = samples.samples().len();
|
||||
if n < n_ch || n % n_ch != 0 {
|
||||
continue;
|
||||
}
|
||||
total += (n / n_ch) as u64;
|
||||
loop_i = loop_i.wrapping_add(1);
|
||||
if loop_i % 128 == 0 {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
None
|
||||
} else {
|
||||
Some((total, timeline_hint))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_peak_bins(bin_max: &[f32]) -> Vec<u8> {
|
||||
let bin_count = bin_max.len();
|
||||
if bin_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut sorted: Vec<f32> = bin_max.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))];
|
||||
let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))];
|
||||
let range = (p99 - p5).max(1e-8);
|
||||
let mut out = vec![0u8; bin_count];
|
||||
for i in 0..bin_count {
|
||||
let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0);
|
||||
let shaped = t.powf(0.52);
|
||||
out[i] = (8.0 + shaped * 247.0).min(255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_scan_pcm(
|
||||
bytes: &[u8],
|
||||
bin_count: usize,
|
||||
decoded_frames: u64,
|
||||
timeline_hint: Option<u64>,
|
||||
loudness_target_lufs: Option<f64>,
|
||||
) -> Option<PcmScanResult> {
|
||||
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 bin_max = vec![0.0f32; bin_count];
|
||||
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 sample_idx: u64 = 0;
|
||||
let mut loop_i: u32 = 0;
|
||||
// Fixed timeline from metadata when available; otherwise fall back to decoded
|
||||
// length (full-buffer analysis only — partial byte windows still shift, but
|
||||
// then we usually lack n_frames anyway).
|
||||
let bin_grid_frames = timeline_hint
|
||||
.map(|n| n.max(decoded_frames))
|
||||
.unwrap_or(decoded_frames)
|
||||
.max(1);
|
||||
|
||||
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();
|
||||
let n_ch = spec.channels.count();
|
||||
if n_ch == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if loudness_target_lufs.is_some() && 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);
|
||||
let slice = samples.samples();
|
||||
if slice.len() < n_ch || slice.len() % n_ch != 0 {
|
||||
continue;
|
||||
}
|
||||
let frames = slice.len() / n_ch;
|
||||
|
||||
for f in 0..frames {
|
||||
let base = f * n_ch;
|
||||
let mut acc = 0.0f32;
|
||||
for c in 0..n_ch {
|
||||
acc += slice[base + c];
|
||||
}
|
||||
let mono = acc / (n_ch as f32);
|
||||
let mag = mono.abs();
|
||||
if mag.is_finite() {
|
||||
let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize;
|
||||
let bin = bin.min(bin_count.saturating_sub(1));
|
||||
bin_max[bin] = bin_max[bin].max(mag);
|
||||
}
|
||||
for c in 0..n_ch {
|
||||
let v = (slice[base + c] as f64).abs();
|
||||
if v.is_finite() && v > sample_peak_abs {
|
||||
sample_peak_abs = v;
|
||||
}
|
||||
}
|
||||
sample_idx += 1;
|
||||
}
|
||||
|
||||
if loudness_target_lufs.is_some() {
|
||||
if let Some(e) = ebu.as_mut() {
|
||||
match e.add_frames_f32(samples.samples()) {
|
||||
Ok(_) => fed_any_frames = true,
|
||||
Err(err) => {
|
||||
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop_i = loop_i.wrapping_add(1);
|
||||
if loop_i % 128 == 0 {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
let bins = normalize_peak_bins(&bin_max);
|
||||
|
||||
let loudness = if let Some(target_lufs) = loudness_target_lufs {
|
||||
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: ebu not initialized");
|
||||
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 {
|
||||
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))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(PcmScanResult { bins, loudness })
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
+727
-26
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod audio;
|
||||
mod analysis_cache;
|
||||
pub mod cli;
|
||||
mod discord;
|
||||
pub(crate) mod logging;
|
||||
@@ -59,6 +60,12 @@ fn sync_cancel_flags() -> &'static Mutex<HashMap<String, Arc<AtomicBool>>> {
|
||||
FLAGS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Tracks analysis backfill jobs already running per track_id.
|
||||
fn analysis_backfill_inflight() -> &'static Mutex<std::collections::HashSet<String>> {
|
||||
static SET: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
|
||||
SET.get_or_init(|| Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
|
||||
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
|
||||
type TrayState = Mutex<Option<TrayIcon>>;
|
||||
@@ -67,6 +74,34 @@ type TrayState = Mutex<Option<TrayIcon>>;
|
||||
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
|
||||
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WaveformCachePayload {
|
||||
bins: Vec<u8>,
|
||||
bin_count: i64,
|
||||
is_partial: bool,
|
||||
known_until_sec: f64,
|
||||
duration_sec: f64,
|
||||
updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WaveformUpdatedPayload {
|
||||
track_id: String,
|
||||
is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LoudnessCachePayload {
|
||||
integrated_lufs: f64,
|
||||
true_peak: f64,
|
||||
recommended_gain_db: f64,
|
||||
target_lufs: f64,
|
||||
updated_at: i64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}!", name)
|
||||
@@ -1158,6 +1193,180 @@ fn check_dir_accessible(path: String) -> bool {
|
||||
std::path::Path::new(&path).is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn analysis_get_waveform(
|
||||
track_id: String,
|
||||
md5_16kb: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let key = analysis_cache::TrackKey {
|
||||
track_id: track_id.clone(),
|
||||
md5_16kb: md5_16kb.clone(),
|
||||
};
|
||||
let row = cache.get_waveform(&key)?;
|
||||
match &row {
|
||||
Some(v) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] db hit (exact key) track_id={} md5_16kb={} bins_len={} bin_count={} updated_at={}",
|
||||
track_id,
|
||||
md5_16kb,
|
||||
v.bins.len(),
|
||||
v.bin_count,
|
||||
v.updated_at
|
||||
);
|
||||
}
|
||||
None => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] db miss (exact key) track_id={} md5_16kb={}",
|
||||
track_id,
|
||||
md5_16kb
|
||||
);
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
match &row {
|
||||
Some(v) => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] db hit track_id={} bins_len={} bin_count={} updated_at={}",
|
||||
track_id,
|
||||
v.bins.len(),
|
||||
v.bin_count,
|
||||
v.updated_at
|
||||
);
|
||||
}
|
||||
None => {
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] db miss track_id={}",
|
||||
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_delete_loudness_for_track(
|
||||
track_id: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_loudness_for_track_id(&track_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn analysis_delete_all_waveforms(
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<u64, String> {
|
||||
cache.delete_all_waveforms()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn analysis_enqueue_seed_from_url(
|
||||
track_id: String,
|
||||
url: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
if track_id.trim().is_empty() || url.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
|
||||
if cache.get_latest_loudness_for_track(&track_id)?.is_some() {
|
||||
crate::app_deprintln!(
|
||||
"[analysis] backfill skip (already cached): {}",
|
||||
track_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut inflight = analysis_backfill_inflight()
|
||||
.lock()
|
||||
.map_err(|_| "analysis backfill lock poisoned".to_string())?;
|
||||
if inflight.contains(&track_id) {
|
||||
return Ok(());
|
||||
}
|
||||
inflight.insert(track_id.clone());
|
||||
}
|
||||
let app_clone = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::app_deprintln!("[analysis] backfill queued: {}", track_id);
|
||||
let result = async {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(subsonic_wire_user_agent())
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status().as_u16()));
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
|
||||
if bytes.is_empty() {
|
||||
return Err("empty response".to_string());
|
||||
}
|
||||
let has_loudness = enqueue_analysis_seed(&app_clone, &track_id, &bytes)?;
|
||||
Ok::<bool, String>(has_loudness)
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(has_loudness) => crate::app_deprintln!(
|
||||
"[analysis] backfill ready: {} (has_loudness={})",
|
||||
track_id,
|
||||
has_loudness
|
||||
),
|
||||
Err(e) => crate::app_eprintln!("[analysis] backfill failed for {}: {}", track_id, e),
|
||||
}
|
||||
if let Ok(mut inflight) = analysis_backfill_inflight().lock() {
|
||||
inflight.remove(&track_id);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Offline Track Cache ──────────────────────────────────────────────────────
|
||||
|
||||
/// Streams an HTTP response body directly to `dest_path` in small chunks.
|
||||
@@ -1178,6 +1387,48 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
|
||||
let outcome = analysis_cache::seed_from_bytes(app, track_id, bytes).map_err(|e| {
|
||||
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
|
||||
e
|
||||
})?;
|
||||
if outcome == analysis_cache::SeedFromBytesOutcome::Upserted {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: track_id.to_string(),
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
let has_loudness = app
|
||||
.try_state::<analysis_cache::AnalysisCache>()
|
||||
.and_then(|cache| cache.get_latest_loudness_for_track(track_id).ok().flatten())
|
||||
.is_some();
|
||||
crate::app_deprintln!(
|
||||
"[analysis] seed result track_id={} bytes={} has_loudness={}",
|
||||
track_id,
|
||||
bytes.len(),
|
||||
has_loudness
|
||||
);
|
||||
Ok(has_loudness)
|
||||
}
|
||||
|
||||
async fn enqueue_analysis_seed_from_file(
|
||||
app: &tauri::AppHandle,
|
||||
track_id: &str,
|
||||
file_path: &std::path::Path,
|
||||
) {
|
||||
let bytes = match tokio::fs::read(file_path).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = enqueue_analysis_seed(app, track_id, &bytes);
|
||||
}
|
||||
|
||||
/// Downloads a single track to the app's offline cache directory.
|
||||
/// Returns the absolute file path so TypeScript can store it and later
|
||||
/// construct a `psysonic-local://<path>` URL for the audio engine.
|
||||
@@ -1244,6 +1495,8 @@ async fn download_track_offline(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
|
||||
|
||||
Ok(path_str)
|
||||
}
|
||||
|
||||
@@ -1792,6 +2045,8 @@ async fn download_track_hot_cache(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
enqueue_analysis_seed_from_file(&app, &track_id, &file_path).await;
|
||||
|
||||
let size = tokio::fs::metadata(&file_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
@@ -1845,6 +2100,8 @@ async fn promote_stream_cache_to_hot_cache(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = enqueue_analysis_seed(&app, &track_id, &bytes);
|
||||
|
||||
let size = tokio::fs::metadata(&file_path)
|
||||
.await
|
||||
.map(|m| m.len())
|
||||
@@ -3609,6 +3866,13 @@ pub fn run() {
|
||||
}))
|
||||
|
||||
.setup(|app| {
|
||||
// ── Analysis cache (SQLite) ───────────────────────────────────
|
||||
{
|
||||
let cache = analysis_cache::AnalysisCache::init(&app.handle())
|
||||
.map_err(|e| format!("analysis cache init failed: {e}"))?;
|
||||
app.manage(cache);
|
||||
}
|
||||
|
||||
// ── Custom title bar on Linux ─────────────────────────────────
|
||||
// Remove OS window decorations on all Linux so the React TitleBar
|
||||
// can take over. The frontend checks is_tiling_wm() to decide
|
||||
@@ -3847,6 +4111,7 @@ pub fn run() {
|
||||
audio::audio_play_radio,
|
||||
audio::audio_set_crossfade,
|
||||
audio::audio_set_gapless,
|
||||
audio::audio_set_normalization,
|
||||
audio::audio_list_devices,
|
||||
audio::audio_canonicalize_selected_device,
|
||||
audio::audio_default_output_device_name,
|
||||
@@ -3878,6 +4143,12 @@ pub fn run() {
|
||||
fetch_json_url,
|
||||
fetch_icy_metadata,
|
||||
resolve_stream_url,
|
||||
analysis_get_waveform,
|
||||
analysis_get_waveform_for_track,
|
||||
analysis_get_loudness_for_track,
|
||||
analysis_delete_loudness_for_track,
|
||||
analysis_delete_all_waveforms,
|
||||
analysis_enqueue_seed_from_url,
|
||||
download_track_offline,
|
||||
delete_offline_track,
|
||||
get_offline_cache_size,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import OrbitGuestQueue from './OrbitGuestQueue';
|
||||
@@ -304,6 +305,9 @@ function QueuePanelHostOrSolo() {
|
||||
const suppressNextAutoScrollRef = useRef(false);
|
||||
|
||||
const playbackSource = usePlayerStore(s => s.currentPlaybackSource);
|
||||
const normalizationNowDb = usePlayerStore(s => s.normalizationNowDb);
|
||||
const normalizationTargetLufs = usePlayerStore(s => s.normalizationTargetLufs);
|
||||
const normalizationEngineLive = usePlayerStore(s => s.normalizationEngineLive);
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
@@ -313,6 +317,8 @@ function QueuePanelHostOrSolo() {
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
|
||||
const replayGainMode = useAuthStore(s => s.replayGainMode);
|
||||
|
||||
const activeTab = useLyricsStore(s => s.activeTab);
|
||||
const setTab = useLyricsStore(s => s.setTab);
|
||||
@@ -320,10 +326,17 @@ function QueuePanelHostOrSolo() {
|
||||
|
||||
const [showRemainingTime, setShowRemainingTime] = useState(false);
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
|
||||
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
|
||||
const lufsTgtBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const lufsTgtMenuRef = useRef<HTMLDivElement>(null);
|
||||
const expandReplayGain = useThemeStore(s => s.expandReplayGain);
|
||||
const setExpandReplayGain = useThemeStore(s => s.setExpandReplayGain);
|
||||
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
|
||||
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
|
||||
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
|
||||
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCrossfadePopover) return;
|
||||
@@ -338,6 +351,64 @@ function QueuePanelHostOrSolo() {
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [showCrossfadePopover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
if (
|
||||
lufsTgtBtnRef.current?.contains(e.target as Node) ||
|
||||
lufsTgtMenuRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setLufsTgtOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handle);
|
||||
return () => document.removeEventListener('mousedown', handle);
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
const updateLufsTgtPopStyle = () => {
|
||||
if (!lufsTgtBtnRef.current) return;
|
||||
const rect = lufsTgtBtnRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const WIDTH = 160;
|
||||
const MAX_H = 220;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 120 && spaceAbove > spaceBelow;
|
||||
const left = Math.min(
|
||||
Math.max(rect.right - WIDTH, 8),
|
||||
window.innerWidth - WIDTH - 8,
|
||||
);
|
||||
setLufsTgtPopStyle({
|
||||
position: 'fixed',
|
||||
left,
|
||||
width: WIDTH,
|
||||
...(useAbove
|
||||
? { bottom: window.innerHeight - rect.top + MARGIN }
|
||||
: { top: rect.bottom + MARGIN }),
|
||||
maxHeight: Math.min(MAX_H, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
updateLufsTgtPopStyle();
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lufsTgtOpen) return;
|
||||
const onResize = () => updateLufsTgtPopStyle();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [lufsTgtOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expandReplayGain) setLufsTgtOpen(false);
|
||||
}, [expandReplayGain]);
|
||||
|
||||
// Tracks which queue index is being psy-dragged for opacity visual feedback
|
||||
const psyDragFromIdxRef = useRef<number | null>(null);
|
||||
|
||||
@@ -534,8 +605,15 @@ function QueuePanelHostOrSolo() {
|
||||
const rgParts = formatQueueReplayGainParts(currentTrack, t);
|
||||
const baseLine = baseParts.join(' · ');
|
||||
const rgLine = rgParts.join(' · ');
|
||||
const isLoudnessActive = normalizationEngine === 'loudness' || normalizationEngineLive === 'loudness';
|
||||
const liveGainLabel = normalizationNowDb != null
|
||||
? `${normalizationNowDb >= 0 ? '+' : ''}${normalizationNowDb.toFixed(2)} dB`
|
||||
: '—';
|
||||
const tgtNum = normalizationTargetLufs ?? authLoudnessTargetLufs;
|
||||
const targetLabel = `${tgtNum} LUFS`;
|
||||
if (!baseLine && !rgLine && !playbackSource) return null;
|
||||
const showRgLine = expandReplayGain && !!rgLine;
|
||||
const showRgLine = !isLoudnessActive && expandReplayGain && !!rgLine;
|
||||
const showLufsLine = isLoudnessActive && expandReplayGain;
|
||||
return (
|
||||
<div className={`queue-current-tech${showRgLine ? ' queue-current-tech--two-line' : ''}`}>
|
||||
<div className="queue-current-tech-stack">
|
||||
@@ -558,7 +636,7 @@ function QueuePanelHostOrSolo() {
|
||||
</span>
|
||||
)}
|
||||
{baseLine && <span className="queue-current-tech-main">{baseLine}</span>}
|
||||
{rgLine && (
|
||||
{!isLoudnessActive && rgLine && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showRgLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
@@ -571,6 +649,19 @@ function QueuePanelHostOrSolo() {
|
||||
<ChevronDown size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
{isLoudnessActive && (
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-current-tech-rg-badge${showLufsLine ? ' queue-current-tech-rg-badge--open' : ''}`}
|
||||
data-tooltip={`LUFS · ${liveGainLabel} · TGT · ${targetLabel}`}
|
||||
aria-expanded={showLufsLine}
|
||||
aria-label="LUFS"
|
||||
onClick={() => setExpandReplayGain(!expandReplayGain)}
|
||||
>
|
||||
LUFS
|
||||
<ChevronDown size={9} strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showRgLine && (
|
||||
<span className="queue-current-tech-rg">
|
||||
@@ -578,6 +669,90 @@ function QueuePanelHostOrSolo() {
|
||||
{' · '}{rgLine}
|
||||
</span>
|
||||
)}
|
||||
{showLufsLine && (
|
||||
<span className="queue-current-tech-rg">
|
||||
<span className="queue-current-tech-rg-label">Loudness</span>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
className="queue-current-tech-metric"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setLufsTgtOpen(false);
|
||||
void reanalyzeLoudnessForTrack(currentTrack.id);
|
||||
}}
|
||||
data-tooltip="Clear cached loudness and re-analyze this track"
|
||||
>
|
||||
{liveGainLabel}
|
||||
</button>
|
||||
{' · '}
|
||||
<span className="queue-current-tech-rg-label">TGT</span>
|
||||
{' · '}
|
||||
<button
|
||||
type="button"
|
||||
ref={lufsTgtBtnRef}
|
||||
className="queue-current-tech-metric"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setLufsTgtOpen(v => !v);
|
||||
}}
|
||||
data-tooltip="Change target integrated loudness"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={lufsTgtOpen}
|
||||
>
|
||||
{targetLabel}
|
||||
</button>
|
||||
{lufsTgtOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={lufsTgtMenuRef}
|
||||
className="queue-lufs-tgt-menu"
|
||||
style={{
|
||||
...lufsTgtPopStyle,
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border, rgba(255,255,255,0.12))',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.35)',
|
||||
padding: 6,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="LUFS target"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{([-10, -12, -14, -16] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (v !== authLoudnessTargetLufs) {
|
||||
setLoudnessTargetLufs(v);
|
||||
void reanalyzeLoudnessForTrack(currentTrack.id);
|
||||
}
|
||||
setLufsTgtOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: v === authLoudnessTargetLufs ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{v} LUFS
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+142
-24
@@ -8,6 +8,8 @@ function fmt(s: number): string {
|
||||
|
||||
const BAR_COUNT = 500;
|
||||
const SEG_COUNT = 60;
|
||||
const FLAT_WAVE_NORM = 0.06;
|
||||
const WAVE_MORPH_MS = 1000;
|
||||
|
||||
// ── animation state ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -96,6 +98,40 @@ export function makeHeights(trackId: string): Float32Array {
|
||||
|
||||
// ── draw functions ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFlatWaveHeights(): Float32Array {
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
h.fill(FLAT_WAVE_NORM);
|
||||
return h;
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
const x = Math.max(0, Math.min(1, t));
|
||||
return 1 - Math.pow(1 - x, 3);
|
||||
}
|
||||
|
||||
function binsToHeights(src: number[]): Float32Array {
|
||||
const h = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
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)));
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function heightsNearlyEqual(a: Float32Array, b: Float32Array, eps: number): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (Math.abs(a[i] - b[i]) > eps) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function waveformBarThickness(logicalH: number, norm: number): number {
|
||||
const safeNorm = Math.max(FLAT_WAVE_NORM, norm);
|
||||
return Math.max(1, safeNorm * logicalH);
|
||||
}
|
||||
|
||||
function drawWaveform(
|
||||
canvas: HTMLCanvasElement,
|
||||
heights: Float32Array | null,
|
||||
@@ -108,9 +144,38 @@ function drawWaveform(
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
|
||||
if (!heights) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
// No waveform data yet: flat rail like `drawLineDot`, but do not return early
|
||||
// before played/buffered — otherwise there is no visible playhead.
|
||||
const cy = h / 2;
|
||||
const lh = 2;
|
||||
const dotR = 5;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, (h - 2) / 2, w, 2);
|
||||
ctx.fillRect(0, cy - lh / 2, w, lh);
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh);
|
||||
}
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (w > 0) {
|
||||
const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w));
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, cy, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = played;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
@@ -122,7 +187,7 @@ function drawWaveform(
|
||||
ctx.fillStyle = unplayed;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT < buffered) continue;
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
@@ -132,7 +197,7 @@ function drawWaveform(
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const frac = i / BAR_COUNT;
|
||||
if (frac < progress || frac >= buffered) continue;
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
@@ -140,29 +205,14 @@ function drawWaveform(
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 5;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (i / BAR_COUNT >= progress) break;
|
||||
const bh = Math.max(1, heights[i] * h);
|
||||
const bh = waveformBarThickness(h, heights[i]);
|
||||
const x = x1Of(i);
|
||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Fade both edges to transparent using destination-in gradient mask
|
||||
const fadeW = Math.min(22, w * 0.07);
|
||||
const mask = ctx.createLinearGradient(0, 0, w, 0);
|
||||
mask.addColorStop(0, 'transparent');
|
||||
mask.addColorStop(fadeW / w, 'black');
|
||||
mask.addColorStop(1 - fadeW / w, 'black');
|
||||
mask.addColorStop(1, 'transparent');
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.fillStyle = mask;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
@@ -832,6 +882,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
const [hoverPct, setHoverPct] = useState<number | null>(null);
|
||||
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const waveformBins = usePlayerStore(s => s.waveformBins);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||
|
||||
@@ -845,8 +896,70 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
heightsRef.current = null;
|
||||
return;
|
||||
}
|
||||
heightsRef.current = makeHeights(trackId);
|
||||
}, [trackId]);
|
||||
if (waveformBins && waveformBins.length > 0) {
|
||||
const h = binsToHeights(waveformBins);
|
||||
const prev = heightsRef.current;
|
||||
if (!prev || prev.length !== BAR_COUNT) {
|
||||
heightsRef.current = h;
|
||||
return;
|
||||
}
|
||||
if (heightsNearlyEqual(prev, h, 0.02)) {
|
||||
heightsRef.current = h;
|
||||
return;
|
||||
}
|
||||
const from = new Float32Array(prev);
|
||||
const to = h;
|
||||
const startedAt = performance.now();
|
||||
let raf = 0;
|
||||
const step = (now: number) => {
|
||||
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
|
||||
const next = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
next[i] = from[i] + (to[i] - from[i]) * p;
|
||||
}
|
||||
heightsRef.current = next;
|
||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
}
|
||||
if (p < 1) raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
if (heightsRef.current?.length === BAR_COUNT) {
|
||||
const current = heightsRef.current;
|
||||
let isAlreadyFlat = true;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) {
|
||||
isAlreadyFlat = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isAlreadyFlat) return;
|
||||
const from = new Float32Array(current);
|
||||
const to = makeFlatWaveHeights();
|
||||
const startedAt = performance.now();
|
||||
let raf = 0;
|
||||
const step = (now: number) => {
|
||||
const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS);
|
||||
const next = new Float32Array(BAR_COUNT);
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
next[i] = from[i] + (to[i] - from[i]) * p;
|
||||
}
|
||||
heightsRef.current = next;
|
||||
if (!ANIMATED_STYLES.has(styleRef.current)) {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current);
|
||||
}
|
||||
if (p < 1) raf = requestAnimationFrame(step);
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
// No analysis bins yet: render 500 flat bars immediately.
|
||||
heightsRef.current = makeFlatWaveHeights();
|
||||
}, [trackId, waveformBins]);
|
||||
|
||||
// Imperative subscription — no React re-renders from progress changes.
|
||||
// Static styles draw here; animated styles only update refs.
|
||||
@@ -874,12 +987,17 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Initial draw for static styles when style or track changes.
|
||||
// Initial draw for static styles when style, track, or waveform payload changes.
|
||||
useEffect(() => {
|
||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current);
|
||||
}, [seekbarStyle, trackId]);
|
||||
}, [
|
||||
seekbarStyle,
|
||||
trackId,
|
||||
waveformBins,
|
||||
duration,
|
||||
]);
|
||||
|
||||
// rAF loop — animated styles only.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -847,6 +847,12 @@ export const deTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (getaggte Dateien)',
|
||||
replayGainFallback: 'Fallback (ohne Tags / Radio)',
|
||||
normalization: 'Normalisierung',
|
||||
loudnessTargetLufs: 'Ziel-LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Dämpfung vor Messung (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Zusätzliche Dämpfung, bis die Loudness des Titels gespeichert ist. Beim Streamen folgen nur grobe Schätzungen, keine volle Messung. 0 dB = aus; negativer = leiser.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Standard',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
@@ -656,9 +656,12 @@ export const enTranslation = {
|
||||
hotCacheTrackCount: 'Tracks in cache:',
|
||||
cacheMaxLabel: 'Max. size',
|
||||
cacheClearBtn: 'Clear Cache',
|
||||
waveformCacheClearBtn: 'Clear waveform cache',
|
||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||
cacheClearConfirm: 'Clear Everything',
|
||||
cacheClearCancel: 'Cancel',
|
||||
waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).',
|
||||
waveformCacheClearFailed: 'Failed to clear waveform cache.',
|
||||
offlineDirTitle: 'Offline Library (In-App)',
|
||||
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
||||
offlineDirDefault: 'Default (App Data)',
|
||||
@@ -850,6 +853,12 @@ export const enTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (tagged files)',
|
||||
replayGainFallback: 'Fallback (untagged / radio)',
|
||||
normalization: 'Normalization',
|
||||
loudnessTargetLufs: 'Target LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Trim before measurement (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Extra quieting until loudness for this track is saved. Streaming then uses rough guesses, not a full measurement. 0 dB = off; lower = quieter.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Reset to default',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
@@ -840,6 +840,12 @@ export const esTranslation = {
|
||||
replayGainAlbum: 'Álbum',
|
||||
replayGainPreGain: 'Pre-Ganancia (archivos etiquetados)',
|
||||
replayGainFallback: 'Respaldo (sin etiquetar / radio)',
|
||||
normalization: 'Normalización',
|
||||
loudnessTargetLufs: 'LUFS objetivo',
|
||||
loudnessPreAnalysisAttenuation: 'Atenuación antes de medir (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Atenúa hasta que la loudness del tema esté guardada. En streaming luego solo hay estimaciones aproximadas. 0 dB = ninguna; más negativo = más bajo.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Predeterminado',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Transición entre pistas',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
@@ -835,6 +835,12 @@ export const frTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pré-Gain (fichiers taggés)',
|
||||
replayGainFallback: 'Repli (sans tags / radio)',
|
||||
normalization: 'Normalisation',
|
||||
loudnessTargetLufs: 'LUFS cible',
|
||||
loudnessPreAnalysisAttenuation: 'Atténuation avant mesure (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Réduit le volume tant que la loudness du morceau n’est pas enregistrée. En streaming, des estimations grossières suivent, pas une mesure complète. 0 dB = rien ; plus négatif = plus discret.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Valeur par défaut',
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
@@ -834,6 +834,12 @@ export const nbTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (taggede filer)',
|
||||
replayGainFallback: 'Reserveverdi (uten tagger / radio)',
|
||||
normalization: 'Normalisering',
|
||||
loudnessTargetLufs: 'Mål-LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Demping før måling (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Ekstra demping til loudness for sporet er lagret. Streaming bruker deretter grove anslag, ikke full måling. 0 dB = av; lavere = roligere.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Standard',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Tone mellom spor',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
|
||||
@@ -834,6 +834,12 @@ export const nlTranslation = {
|
||||
replayGainAlbum: 'Album',
|
||||
replayGainPreGain: 'Pre-Gain (getagde bestanden)',
|
||||
replayGainFallback: 'Terugval (zonder tags / radio)',
|
||||
normalization: 'Normalisatie',
|
||||
loudnessTargetLufs: 'Doel-LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Demping vóór meting (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Extra zachter tot de loudness van het nummer is opgeslagen. Streamen gebruikt daarna grove schattingen, geen volledige meting. 0 dB = uit; negatiever = zachter.',
|
||||
loudnessPreAnalysisAttenuationReset: 'Standaard',
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
|
||||
@@ -677,9 +677,12 @@ export const ruTranslation = {
|
||||
hotCacheTrackCount: 'Треков в кэше:',
|
||||
cacheMaxLabel: 'Лимит',
|
||||
cacheClearBtn: 'Очистить кэш',
|
||||
waveformCacheClearBtn: 'Очистить кэш waveform',
|
||||
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
|
||||
cacheClearConfirm: 'Очистить всё',
|
||||
cacheClearCancel: 'Отмена',
|
||||
waveformCacheCleared: 'Кэш waveform очищен ({{count}} строк).',
|
||||
waveformCacheClearFailed: 'Не удалось очистить кэш waveform.',
|
||||
offlineDirTitle: 'Офлайн-библиотека (в приложении)',
|
||||
offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.',
|
||||
offlineDirDefault: 'По умолчанию (данные приложения)',
|
||||
@@ -883,6 +886,12 @@ export const ruTranslation = {
|
||||
replayGainAlbum: 'По альбому',
|
||||
replayGainPreGain: 'Предусиление (файлы с тегами)',
|
||||
replayGainFallback: 'Резерв (без тегов / радио)',
|
||||
normalization: 'Нормализация',
|
||||
loudnessTargetLufs: 'Целевой LUFS',
|
||||
loudnessPreAnalysisAttenuation: 'Ослабление до измерения (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'Насколько приглушить звук, пока для трека нет сохранённого измерения громкости. При стриме дальше идут лишь грубые оценки, не полный LUFS. 0 dB — без дополнительного приглушения; ниже — тише.',
|
||||
loudnessPreAnalysisAttenuationReset: 'По умолчанию',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
|
||||
@@ -829,6 +829,12 @@ export const zhTranslation = {
|
||||
replayGainAlbum: '专辑',
|
||||
replayGainPreGain: '预增益(有标签文件)',
|
||||
replayGainFallback: '回退增益(无标签 / 收音机)',
|
||||
normalization: '响度归一化',
|
||||
loudnessTargetLufs: '目标 LUFS',
|
||||
loudnessPreAnalysisAttenuation: '测量前衰减 (dB)',
|
||||
loudnessPreAnalysisAttenuationDesc:
|
||||
'在曲目响度数据保存前的额外压低。流式阶段之后只是粗略估计,不是完整测量。0 dB 为不压低;更负则更安静。',
|
||||
loudnessPreAnalysisAttenuationReset: '恢复默认',
|
||||
crossfade: '交叉淡入淡出',
|
||||
crossfadeDesc: '曲目间淡入淡出',
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
|
||||
@@ -30,6 +30,22 @@ try {
|
||||
// Ignore in non-Tauri runtimes.
|
||||
}
|
||||
|
||||
// Zustand rehydrate runs after first paint; AppShell's useEffect can miss the
|
||||
// user's persisted `loggingMode` until then — but waveform/audio may already
|
||||
// run. Push persisted mode to Rust before React mounts (matches `psysonic-auth`).
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic-auth');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { state?: { loggingMode?: string } };
|
||||
const mode = parsed.state?.loggingMode;
|
||||
if (mode === 'off' || mode === 'normal' || mode === 'debug') {
|
||||
void invoke('set_logging_mode', { mode });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse / non-Tauri.
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+147
-12
@@ -17,6 +17,7 @@ import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
@@ -25,7 +26,17 @@ import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol';
|
||||
import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
|
||||
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore';
|
||||
import {
|
||||
useAuthStore,
|
||||
DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||
ServerProfile,
|
||||
MIX_MIN_RATING_FILTER_MAX_STARS,
|
||||
type SeekbarStyle,
|
||||
type LyricsSourceId,
|
||||
type LyricsSourceConfig,
|
||||
type LoggingMode,
|
||||
type LoudnessLufsPreset,
|
||||
} from '../store/authStore';
|
||||
import { SeekbarPreview } from '../components/WaveformSeek';
|
||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
@@ -64,6 +75,29 @@ const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspie
|
||||
|
||||
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
|
||||
|
||||
const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16];
|
||||
|
||||
function LoudnessLufsButtonGroup(props: {
|
||||
value: LoudnessLufsPreset;
|
||||
onSelect: (v: LoudnessLufsPreset) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||||
{LOUDNESS_LUFS_BUTTON_ORDER.map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
className={`btn ${props.value === v ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => props.onSelect(v)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CONTRIBUTORS = [
|
||||
{
|
||||
github: 'jiezhuo',
|
||||
@@ -1788,6 +1822,26 @@ export default function Settings() {
|
||||
setClearing(false);
|
||||
}, [clearAllOffline, serverId]);
|
||||
|
||||
const handleClearWaveformCache = useCallback(async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const deleted = await invoke<number>('analysis_delete_all_waveforms');
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
});
|
||||
showToast(
|
||||
t('settings.waveformCacheCleared', { count: deleted }),
|
||||
3500,
|
||||
'success',
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(t('settings.waveformCacheClearFailed'), 4500, 'error');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const startLastfmConnect = useCallback(async () => {
|
||||
setLfmError(null);
|
||||
let token: string;
|
||||
@@ -2217,19 +2271,89 @@ export default function Settings() {
|
||||
icon={<Music2 size={16} />}
|
||||
>
|
||||
<div className="settings-card">
|
||||
{/* Replay Gain */}
|
||||
{/* Normalization */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.replayGain')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.replayGainDesc')}</div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={`btn ${auth.normalizationEngine === 'off' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 10px' }}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(false);
|
||||
auth.setNormalizationEngine('off');
|
||||
}}
|
||||
>
|
||||
Off
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.normalizationEngine === 'replaygain' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 10px' }}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(true);
|
||||
auth.setNormalizationEngine('replaygain');
|
||||
}}
|
||||
>
|
||||
RG
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.normalizationEngine === 'loudness' ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 10px' }}
|
||||
onClick={() => {
|
||||
auth.setReplayGainEnabled(false);
|
||||
if (auth.normalizationEngine !== 'loudness') auth.setLoudnessTargetLufs(-12);
|
||||
auth.setNormalizationEngine('loudness');
|
||||
}}
|
||||
>
|
||||
LUFS
|
||||
</button>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.replayGain')}>
|
||||
<input type="checkbox" checked={auth.replayGainEnabled} onChange={e => auth.setReplayGainEnabled(e.target.checked)} id="replay-gain-toggle" />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{auth.replayGainEnabled && (
|
||||
{auth.normalizationEngine === 'loudness' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.55rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
|
||||
{t('settings.loudnessTargetLufs')}
|
||||
</span>
|
||||
<LoudnessLufsButtonGroup value={auth.loudnessTargetLufs} onSelect={auth.setLoudnessTargetLufs} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 140 }}>
|
||||
{t('settings.loudnessPreAnalysisAttenuation')}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-24}
|
||||
max={0}
|
||||
step={0.5}
|
||||
value={auth.loudnessPreAnalysisAttenuationDb}
|
||||
onChange={e => auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', minWidth: 44, textAlign: 'right' }}>
|
||||
{auth.loudnessPreAnalysisAttenuationDb} dB
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
style={{ flexShrink: 0 }}
|
||||
disabled={
|
||||
auth.loudnessPreAnalysisAttenuationDb === DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB
|
||||
}
|
||||
onClick={() => auth.resetLoudnessPreAnalysisAttenuationDbDefault()}
|
||||
data-tooltip={t('settings.loudnessPreAnalysisAttenuationReset')}
|
||||
aria-label={t('settings.loudnessPreAnalysisAttenuationReset')}
|
||||
>
|
||||
<RotateCcw size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.35, maxWidth: 520 }}>
|
||||
{t('settings.loudnessPreAnalysisAttenuationDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auth.normalizationEngine !== 'off' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{auth.normalizationEngine === 'replaygain' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
||||
<button
|
||||
@@ -2254,14 +2378,15 @@ export default function Settings() {
|
||||
{t('settings.replayGainAlbum')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.replayGainMode === 'auto' && (
|
||||
)}
|
||||
{auth.normalizationEngine === 'replaygain' && auth.replayGainMode === 'auto' && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.replayGainAutoDesc')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{auth.replayGainEnabled && (
|
||||
{auth.normalizationEngine === 'replaygain' && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 100 }}>
|
||||
@@ -2964,6 +3089,16 @@ export default function Settings() {
|
||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||
</button>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13 }}
|
||||
onClick={handleClearWaveformCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
<Trash2 size={14} /> {t('settings.waveformCacheClearBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
|
||||
@@ -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,28 @@ export interface ServerProfile {
|
||||
|
||||
export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||
export type LoggingMode = 'off' | 'normal' | 'debug';
|
||||
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
|
||||
|
||||
/** Integrated-loudness target presets (Settings + analysis). */
|
||||
export type LoudnessLufsPreset = -16 | -14 | -12 | -10;
|
||||
|
||||
const LOUDNESS_LUFS_PRESETS: LoudnessLufsPreset[] = [-16, -14, -12, -10];
|
||||
|
||||
/** Settings default + Rust engine cold default until `audio_set_normalization` runs. */
|
||||
export const DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB = -4.5;
|
||||
|
||||
function sanitizeLoudnessLufsPreset(v: unknown, fallback: LoudnessLufsPreset): LoudnessLufsPreset {
|
||||
return (LOUDNESS_LUFS_PRESETS as readonly number[]).includes(v as number)
|
||||
? (v as LoudnessLufsPreset)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function clampLoudnessPreAnalysisAttenuationDb(v: unknown): number {
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB;
|
||||
const stepped = Math.round(n * 2) / 2;
|
||||
return Math.max(-24, Math.min(0, stepped));
|
||||
}
|
||||
|
||||
export type LyricsSourceId = 'server' | 'lrclib' | 'netease';
|
||||
export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; }
|
||||
@@ -49,6 +71,10 @@ interface AuthState {
|
||||
excludeAudiobooks: boolean;
|
||||
customGenreBlacklist: string[];
|
||||
replayGainEnabled: boolean;
|
||||
normalizationEngine: NormalizationEngine;
|
||||
loudnessTargetLufs: LoudnessLufsPreset;
|
||||
/** dB: extra quieting until loudness is saved; also seeds streaming rough-guess ramp. */
|
||||
loudnessPreAnalysisAttenuationDb: number;
|
||||
replayGainMode: 'track' | 'album' | 'auto';
|
||||
replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB)
|
||||
replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB)
|
||||
@@ -208,6 +234,10 @@ interface AuthState {
|
||||
setExcludeAudiobooks: (v: boolean) => void;
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
setReplayGainEnabled: (v: boolean) => void;
|
||||
setNormalizationEngine: (v: NormalizationEngine) => void;
|
||||
setLoudnessTargetLufs: (v: LoudnessLufsPreset) => void;
|
||||
setLoudnessPreAnalysisAttenuationDb: (v: number) => void;
|
||||
resetLoudnessPreAnalysisAttenuationDbDefault: () => void;
|
||||
setReplayGainMode: (v: 'track' | 'album' | 'auto') => void;
|
||||
setReplayGainPreGainDb: (v: number) => void;
|
||||
setReplayGainFallbackDb: (v: number) => void;
|
||||
@@ -319,6 +349,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
excludeAudiobooks: false,
|
||||
customGenreBlacklist: [],
|
||||
replayGainEnabled: false,
|
||||
normalizationEngine: 'off',
|
||||
loudnessTargetLufs: -12,
|
||||
loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||
replayGainMode: 'auto',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: 0,
|
||||
@@ -447,6 +480,21 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ replayGainEnabled: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setNormalizationEngine: (v) => {
|
||||
set({ normalizationEngine: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setLoudnessTargetLufs: (v) => {
|
||||
set({ loudnessTargetLufs: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setLoudnessPreAnalysisAttenuationDb: (v) => {
|
||||
set({ loudnessPreAnalysisAttenuationDb: clampLoudnessPreAnalysisAttenuationDb(v) });
|
||||
},
|
||||
resetLoudnessPreAnalysisAttenuationDbDefault: () => {
|
||||
set({ loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
},
|
||||
setReplayGainMode: (v) => {
|
||||
set({ replayGainMode: v });
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
@@ -681,6 +729,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const st = state as { loudnessTargetLufs?: unknown; loudnessPreAnalysisAttenuationDb?: unknown };
|
||||
const targetSan = sanitizeLoudnessLufsPreset(st.loudnessTargetLufs, -12);
|
||||
const preSan = clampLoudnessPreAnalysisAttenuationDb(st.loudnessPreAnalysisAttenuationDb);
|
||||
|
||||
useAuthStore.setState({
|
||||
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
|
||||
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
|
||||
@@ -688,6 +740,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||
),
|
||||
loudnessTargetLufs: targetSan,
|
||||
loudnessPreAnalysisAttenuationDb: preSan,
|
||||
...conflictingLegacyState,
|
||||
...lyricsSourcesMigrated,
|
||||
...wheelSmoothOneTime,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
|
||||
import type { Track } from './playerStore';
|
||||
import { emitAnalysisStorageChanged } from './analysisSync';
|
||||
|
||||
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
|
||||
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
|
||||
@@ -96,6 +97,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
delete next[entryKey(serverId, trackId)];
|
||||
return { entries: next };
|
||||
});
|
||||
emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' });
|
||||
},
|
||||
|
||||
totalBytes: () =>
|
||||
@@ -167,6 +169,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
}).catch(() => {});
|
||||
sum -= meta.sizeBytes || 0;
|
||||
delete entries[key];
|
||||
emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' });
|
||||
}
|
||||
|
||||
set({ entries });
|
||||
@@ -175,6 +178,7 @@ export const useHotCacheStore = create<HotCacheState>()(
|
||||
clearAllDisk: async (customDir: string | null) => {
|
||||
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
|
||||
set({ entries: {} });
|
||||
emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' });
|
||||
},
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SubsonicSong } from '../api/subsonic';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
|
||||
import { emitAnalysisStorageChanged } from './analysisSync';
|
||||
|
||||
export interface OfflineTrackMeta {
|
||||
id: string;
|
||||
@@ -299,6 +300,9 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
}).catch(() => {});
|
||||
}),
|
||||
);
|
||||
for (const trackId of album.trackIds) {
|
||||
emitAnalysisStorageChanged({ trackId, reason: 'offline-delete' });
|
||||
}
|
||||
|
||||
set(state => {
|
||||
const tracks = { ...state.tracks };
|
||||
|
||||
+552
-14
@@ -10,6 +10,7 @@ import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTr
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useOfflineStore } from './offlineStore';
|
||||
import { useHotCacheStore } from './hotCacheStore';
|
||||
import { onAnalysisStorageChanged } from './analysisSync';
|
||||
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
|
||||
import { useOrbitStore } from './orbitStore';
|
||||
import { estimateLivePosition } from '../api/orbit';
|
||||
@@ -146,6 +147,16 @@ async function buildInfiniteQueueCandidates(
|
||||
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
waveformBins: number[] | null;
|
||||
normalizationNowDb: number | null;
|
||||
normalizationTargetLufs: number | null;
|
||||
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
|
||||
normalizationDbgSource: string | null;
|
||||
normalizationDbgTrackId: string | null;
|
||||
normalizationDbgCacheGainDb: number | null;
|
||||
normalizationDbgCacheTargetLufs: number | null;
|
||||
normalizationDbgCacheUpdatedAt: number | null;
|
||||
normalizationDbgLastEventAt: number | null;
|
||||
currentRadio: InternetRadioStation | null;
|
||||
/** Latches the source used to start the currently playing track. */
|
||||
currentPlaybackSource: PlaybackSourceKind | null;
|
||||
@@ -195,8 +206,9 @@ interface PlayerState {
|
||||
next: (manual?: boolean) => void;
|
||||
previous: () => void;
|
||||
seek: (progress: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
updateReplayGainForCurrentTrack: () => void;
|
||||
reanalyzeLoudnessForTrack: (trackId: string) => Promise<void>;
|
||||
setProgress: (t: number, duration: number) => void;
|
||||
enqueue: (tracks: Track[], _orbitConfirmed?: boolean) => void;
|
||||
enqueueAt: (tracks: Track[], insertIndex: number, _orbitConfirmed?: boolean) => void;
|
||||
@@ -248,6 +260,51 @@ interface PlayerState {
|
||||
closeSongInfo: () => void;
|
||||
}
|
||||
|
||||
type WaveformCachePayload = {
|
||||
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
|
||||
bins: number[] | Uint8Array;
|
||||
binCount: number;
|
||||
isPartial: boolean;
|
||||
knownUntilSec: number;
|
||||
durationSec: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/** `Vec<u8>` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */
|
||||
function coerceWaveformBins(bins: unknown): number[] | null {
|
||||
if (bins == null) return null;
|
||||
if (Array.isArray(bins)) {
|
||||
return bins.length > 0 ? bins.map(x => Number(x) & 255) : null;
|
||||
}
|
||||
if (bins instanceof Uint8Array) {
|
||||
return bins.length > 0 ? Array.from(bins) : null;
|
||||
}
|
||||
if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') {
|
||||
const len = (bins as { length: number }).length;
|
||||
if (len === 0) return null;
|
||||
try {
|
||||
return Array.from(bins as ArrayLike<number>).map(x => Number(x) & 255);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type LoudnessCachePayload = {
|
||||
integratedLufs: number;
|
||||
truePeak: number;
|
||||
recommendedGainDb: number;
|
||||
targetLufs: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type NormalizationStatePayload = {
|
||||
engine: 'off' | 'replaygain' | 'loudness' | string;
|
||||
currentGainDb: number | null;
|
||||
targetLufs: number;
|
||||
};
|
||||
|
||||
// ─── Module-level playback primitives ─────────────────────────────────────────
|
||||
|
||||
// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track.
|
||||
@@ -266,6 +323,66 @@ let radioFetching = false;
|
||||
// Artist ID used to start the current radio session — persists across track
|
||||
// advances so proactive loading works even when songs lack artistId.
|
||||
let currentRadioArtistId: string | null = null;
|
||||
let cachedLoudnessGainByTrackId: Record<string, number> = {};
|
||||
let stableLoudnessGainByTrackId: Record<string, true> = {};
|
||||
let lastNormalizationUiUpdateAtMs = 0;
|
||||
|
||||
function emitNormalizationDebug(step: string, details?: Record<string, unknown>) {
|
||||
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'normalization',
|
||||
message: JSON.stringify({ step, details }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function normalizeAnalysisTrackId(trackId?: string | null): string | null {
|
||||
if (!trackId) return null;
|
||||
if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length);
|
||||
return trackId;
|
||||
}
|
||||
|
||||
function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
return Math.abs(a - b) <= eps;
|
||||
}
|
||||
|
||||
function deriveNormalizationSnapshot(
|
||||
track: Track,
|
||||
queue: Track[],
|
||||
queueIndex: number,
|
||||
): Pick<
|
||||
PlayerState,
|
||||
'normalizationNowDb' | 'normalizationTargetLufs' | 'normalizationEngineLive'
|
||||
> {
|
||||
const auth = useAuthStore.getState();
|
||||
const engine = auth.normalizationEngine;
|
||||
if (engine === 'loudness') {
|
||||
const target = auth.loudnessTargetLufs;
|
||||
return {
|
||||
// Clears stale UI until `audio:normalization-state` / refresh catches up.
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: target,
|
||||
normalizationEngineLive: 'loudness',
|
||||
};
|
||||
}
|
||||
if (engine === 'replaygain' && auth.replayGainEnabled) {
|
||||
const prev = queueIndex > 0 ? queue[queueIndex - 1] : null;
|
||||
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
||||
const resolved = resolveReplayGainDb(track, prev, next, true, auth.replayGainMode);
|
||||
const nowDb = resolved != null ? (resolved + auth.replayGainPreGainDb) : auth.replayGainFallbackDb;
|
||||
return {
|
||||
normalizationNowDb: nowDb,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'replaygain',
|
||||
};
|
||||
}
|
||||
return {
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
};
|
||||
}
|
||||
|
||||
// Debounce timer for seek slider drags.
|
||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -274,6 +391,9 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
let seekTarget: number | null = null;
|
||||
let seekTargetSetAt = 0;
|
||||
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
|
||||
const analysisBackfillInFlightByTrackId: Record<string, true> = {};
|
||||
const analysisBackfillAttemptsByTrackId: Record<string, number> = {};
|
||||
const MAX_BACKFILL_ATTEMPTS_PER_TRACK = 2;
|
||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seekFallbackRetryStartedAt = 0;
|
||||
@@ -488,6 +608,185 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||
}
|
||||
|
||||
function isReplayGainActive() {
|
||||
const a = useAuthStore.getState();
|
||||
return a.normalizationEngine === 'replaygain' && a.replayGainEnabled;
|
||||
}
|
||||
|
||||
function loudnessCacheStateKeysForTrackId(trackId: string): string[] {
|
||||
if (!trackId) return [];
|
||||
const out: string[] = [trackId];
|
||||
if (trackId.startsWith('stream:')) {
|
||||
const bare = trackId.slice('stream:'.length);
|
||||
if (bare) out.push(bare);
|
||||
} else {
|
||||
out.push(`stream:${trackId}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clearLoudnessCacheStateForTrackId(trackId: string) {
|
||||
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
|
||||
delete cachedLoudnessGainByTrackId[k];
|
||||
delete stableLoudnessGainByTrackId[k];
|
||||
}
|
||||
}
|
||||
|
||||
/** Pass to `audio_play` / `audio_chain_preload` only — DB-backed gain. Omit partial hints so Rust uses pre-trim until `analysis:loudness-partial` + `audio_update_replay_gain`. */
|
||||
function loudnessGainDbForEngineBind(trackId: string | undefined | null): number | null {
|
||||
if (!trackId) return null;
|
||||
if (!stableLoudnessGainByTrackId[trackId]) return null;
|
||||
const v = cachedLoudnessGainByTrackId[trackId];
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function resetLoudnessBackfillStateForTrackId(trackId: string) {
|
||||
for (const k of loudnessCacheStateKeysForTrackId(trackId)) {
|
||||
delete analysisBackfillInFlightByTrackId[k];
|
||||
analysisBackfillAttemptsByTrackId[k] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function reseedLoudnessForTrackId(trackId: string) {
|
||||
if (!trackId) return;
|
||||
const auth = useAuthStore.getState();
|
||||
if (auth.normalizationEngine !== 'loudness') return;
|
||||
clearLoudnessCacheStateForTrackId(trackId);
|
||||
resetLoudnessBackfillStateForTrackId(trackId);
|
||||
if (auth.normalizationEngine === 'loudness') {
|
||||
usePlayerStore.setState({
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: auth.loudnessTargetLufs,
|
||||
normalizationEngineLive: 'loudness',
|
||||
});
|
||||
}
|
||||
try {
|
||||
await invoke('analysis_delete_loudness_for_track', { trackId });
|
||||
} catch (e) {
|
||||
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
|
||||
}
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
const url = buildStreamUrl(trackId);
|
||||
try {
|
||||
await invoke('analysis_enqueue_seed_from_url', { trackId, url });
|
||||
} catch (e) {
|
||||
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWaveformForTrack(trackId: string) {
|
||||
if (!trackId) return;
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
|
||||
// Never apply bins for a non-current track (e.g. gapless byte-preload fetches the neighbour).
|
||||
if (usePlayerStore.getState().currentTrack?.id !== trackId) return;
|
||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||
if (!bins || bins.length === 0) {
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
usePlayerStore.setState({
|
||||
waveformBins: bins,
|
||||
});
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
||||
/** After bulk enqueue, warm loudness cache so gapless `audio_chain_preload` sees real gain, not only startup trim. */
|
||||
const LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS = 40;
|
||||
|
||||
function prefetchLoudnessForEnqueuedTracks(
|
||||
incoming: Track[],
|
||||
mergedQueue: Track[],
|
||||
queueIndex: number,
|
||||
) {
|
||||
if (useAuthStore.getState().normalizationEngine !== 'loudness') return;
|
||||
const ids = new Set<string>();
|
||||
const next = mergedQueue[queueIndex + 1];
|
||||
if (next?.id) ids.add(next.id);
|
||||
let n = 0;
|
||||
for (const t of incoming) {
|
||||
if (n >= LOUDNESS_PREFETCH_MAX_ENQUEUED_IDS) break;
|
||||
if (t?.id) {
|
||||
ids.add(t.id);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
void refreshLoudnessForTrack(id, { syncPlayingEngine: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
|
||||
try {
|
||||
const res = await invoke<{ path: string; size: number } | null>(
|
||||
@@ -648,6 +947,9 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
||||
bytePreloadingId = nextTrack.id;
|
||||
// Loudness cache only — do not call refreshWaveformForTrack(next): it writes global
|
||||
// waveformBins and would replace the current track's seekbar while still playing it.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
if (import.meta.env.DEV) {
|
||||
console.info('[psysonic][preload-request]', {
|
||||
nextTrackId: nextTrack.id,
|
||||
@@ -658,12 +960,18 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
gaplessEnabled,
|
||||
});
|
||||
}
|
||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||
invoke('audio_preload', {
|
||||
url: nextUrl,
|
||||
durationHint: nextTrack.duration,
|
||||
analysisTrackId: nextTrack.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Gapless chain — decode + chain into Sink 30s before track boundary.
|
||||
if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) {
|
||||
gaplessPreloadingId = nextTrack.id;
|
||||
// Ensure loudness gain is already cached for the chained request payload.
|
||||
void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false });
|
||||
const authState = useAuthStore.getState();
|
||||
// Auto-mode neighbours for the *next* track: current track on its left,
|
||||
// queue[nextIdx+1] on its right.
|
||||
@@ -672,9 +980,9 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
: (repeatMode === 'all' && queue.length > 0 ? queue[0] : null);
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
nextTrack, track, nextNeighbour,
|
||||
authState.replayGainEnabled, authState.replayGainMode,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
const replayGainPeak = isReplayGainActive()
|
||||
? (nextTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
invoke('audio_chain_preload', {
|
||||
@@ -683,9 +991,11 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: nextTrack.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -707,7 +1017,12 @@ function handleAudioEnded() {
|
||||
|
||||
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
|
||||
isAudioPaused = false;
|
||||
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
|
||||
usePlayerStore.setState({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
usePlayerStore.getState().playTrack(currentTrack, queue, false);
|
||||
@@ -751,6 +1066,10 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
|
||||
usePlayerStore.setState({
|
||||
currentTrack: nextTrack,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
|
||||
normalizationDbgSource: 'track-switched',
|
||||
normalizationDbgTrackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
isPlaying: true,
|
||||
progress: 0,
|
||||
@@ -759,6 +1078,14 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
});
|
||||
emitNormalizationDebug('track-switched', {
|
||||
trackId: nextTrack.id,
|
||||
queueIndex: newIndex,
|
||||
engineRequested: useAuthStore.getState().normalizationEngine,
|
||||
});
|
||||
void refreshWaveformForTrack(nextTrack.id);
|
||||
void refreshLoudnessForTrack(nextTrack.id);
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
|
||||
// Report Now Playing to Navidrome + Last.fm
|
||||
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
|
||||
@@ -824,6 +1151,83 @@ export function initAudioListeners(): () => void {
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
listen<{ trackId?: string | null; 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 currentRaw = usePlayerStore.getState().currentTrack?.id;
|
||||
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
|
||||
if (currentId && payloadTrackId === currentId) {
|
||||
void refreshWaveformForTrack(currentRaw!);
|
||||
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();
|
||||
const isFirstNumericGain =
|
||||
engine === 'loudness'
|
||||
&& nowDb != null
|
||||
&& prev.normalizationNowDb == null;
|
||||
if (
|
||||
!isFirstNumericGain
|
||||
&& nowMs - lastNormalizationUiUpdateAtMs < 120
|
||||
&& engine === prev.normalizationEngineLive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastNormalizationUiUpdateAtMs = nowMs;
|
||||
emitNormalizationDebug('event:audio:normalization-state', {
|
||||
trackId: usePlayerStore.getState().currentTrack?.id ?? null,
|
||||
payload,
|
||||
});
|
||||
usePlayerStore.setState({
|
||||
normalizationEngineLive: engine,
|
||||
normalizationNowDb: nowDb,
|
||||
normalizationTargetLufs: targetLufs,
|
||||
normalizationDbgSource: 'event:audio:normalization-state',
|
||||
normalizationDbgLastEventAt: Date.now(),
|
||||
});
|
||||
}),
|
||||
listen<string>('audio:preload-ready', ({ payload }) => {
|
||||
const tid = streamUrlTrackId(payload);
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -847,17 +1251,98 @@ export function initAudioListeners(): () => void {
|
||||
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled, audioOutputDevice } = useAuthStore.getState();
|
||||
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
|
||||
const normCfg = useAuthStore.getState();
|
||||
usePlayerStore.setState({
|
||||
normalizationEngineLive: normCfg.normalizationEngine,
|
||||
normalizationTargetLufs: normCfg.normalizationEngine === 'loudness' ? normCfg.loudnessTargetLufs : null,
|
||||
normalizationNowDb: null,
|
||||
normalizationDbgSource: 'init:set-normalization',
|
||||
});
|
||||
emitNormalizationDebug('init:set-normalization', {
|
||||
engine: normCfg.normalizationEngine,
|
||||
targetLufs: normCfg.loudnessTargetLufs,
|
||||
currentTrackId: usePlayerStore.getState().currentTrack?.id ?? null,
|
||||
});
|
||||
invoke('audio_set_normalization', {
|
||||
engine: normCfg.normalizationEngine,
|
||||
targetLufs: normCfg.loudnessTargetLufs,
|
||||
preAnalysisAttenuationDb: normCfg.loudnessPreAnalysisAttenuationDb,
|
||||
}).catch(() => {});
|
||||
const bootTrackId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (bootTrackId) {
|
||||
void refreshWaveformForTrack(bootTrackId);
|
||||
}
|
||||
if (normCfg.normalizationEngine === 'loudness') {
|
||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (currentId) {
|
||||
void refreshLoudnessForTrack(currentId).finally(() => {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (audioOutputDevice) {
|
||||
invoke('audio_set_device', { deviceName: audioOutputDevice }).catch(() => {});
|
||||
}
|
||||
|
||||
// Keep audio settings in sync whenever auth store changes.
|
||||
let prevNormEngine = normCfg.normalizationEngine;
|
||||
let prevNormTarget = normCfg.loudnessTargetLufs;
|
||||
let prevPreAnalysis = normCfg.loudnessPreAnalysisAttenuationDb;
|
||||
const unsubAuth = useAuthStore.subscribe((state) => {
|
||||
invoke('audio_set_crossfade', {
|
||||
enabled: state.crossfadeEnabled,
|
||||
secs: state.crossfadeSecs,
|
||||
}).catch(() => {});
|
||||
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
|
||||
const normChanged =
|
||||
state.normalizationEngine !== prevNormEngine
|
||||
|| state.loudnessTargetLufs !== prevNormTarget
|
||||
|| state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
|
||||
if (!normChanged) return;
|
||||
const onlyPreAnalysisChanged =
|
||||
state.normalizationEngine === prevNormEngine
|
||||
&& state.loudnessTargetLufs === prevNormTarget
|
||||
&& state.loudnessPreAnalysisAttenuationDb !== prevPreAnalysis;
|
||||
prevNormEngine = state.normalizationEngine;
|
||||
prevNormTarget = state.loudnessTargetLufs;
|
||||
prevPreAnalysis = state.loudnessPreAnalysisAttenuationDb;
|
||||
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,
|
||||
preAnalysisAttenuationDb: state.loudnessPreAnalysisAttenuationDb,
|
||||
}).catch(() => {});
|
||||
if (state.normalizationEngine === 'loudness') {
|
||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||
if (onlyPreAnalysisChanged) {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
} else 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 ───────────────────────────────────────
|
||||
@@ -1019,6 +1504,7 @@ export function initAudioListeners(): () => void {
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
unsubAnalysisSync();
|
||||
unsubMpris();
|
||||
unsubDiscordPlayer();
|
||||
unsubDiscordAuth();
|
||||
@@ -1033,6 +1519,16 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
currentTrack: null,
|
||||
waveformBins: null,
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
normalizationDbgSource: null,
|
||||
normalizationDbgTrackId: null,
|
||||
normalizationDbgCacheGainDb: null,
|
||||
normalizationDbgCacheTargetLufs: null,
|
||||
normalizationDbgCacheUpdatedAt: null,
|
||||
normalizationDbgLastEventAt: null,
|
||||
currentRadio: null,
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
@@ -1159,6 +1655,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
currentPlaybackSource: null,
|
||||
enginePreloadedTrackId: null,
|
||||
scheduledPauseAtMs: null,
|
||||
@@ -1199,6 +1699,10 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
set({
|
||||
currentRadio: station,
|
||||
currentTrack: null,
|
||||
waveformBins: null,
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
currentPlaybackSource: null,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
@@ -1306,6 +1810,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
set({
|
||||
currentTrack: track,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
progress: initialProgress,
|
||||
@@ -1330,24 +1836,28 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
authState.hotCacheDownloadDir || null,
|
||||
);
|
||||
}
|
||||
void refreshWaveformForTrack(track.id);
|
||||
void refreshLoudnessForTrack(track.id);
|
||||
setDeferHotCachePrefetch(true);
|
||||
const playIdx = idx >= 0 ? idx : 0;
|
||||
const nextNeighbour = playIdx + 1 < newQueue.length ? newQueue[playIdx + 1] : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
track, prevTrack, nextNeighbour,
|
||||
authState.replayGainEnabled, authState.replayGainMode,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null;
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
invoke('audio_play', {
|
||||
url,
|
||||
volume: state.volume,
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: track.id,
|
||||
})
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
@@ -1511,9 +2021,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = resolveReplayGainDb(
|
||||
trackToPlay, coldPrev, coldNext,
|
||||
authStateCold.replayGainEnabled, authStateCold.replayGainMode,
|
||||
isReplayGainActive(), authStateCold.replayGainMode,
|
||||
);
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||
@@ -1524,10 +2034,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(trackToPlay.id),
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
analysisTrackId: trackToPlay.id,
|
||||
}).then(() => {
|
||||
if (playGeneration === gen && currentTime > 1) {
|
||||
invoke('audio_seek', { seconds: currentTime }).catch(console.error);
|
||||
@@ -1545,9 +2057,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const authStateCold = useAuthStore.getState();
|
||||
const replayGainDbCold = resolveReplayGainDb(
|
||||
currentTrack, coldPrev, coldNext,
|
||||
authStateCold.replayGainEnabled, authStateCold.replayGainMode,
|
||||
isReplayGainActive(), authStateCold.replayGainMode,
|
||||
);
|
||||
const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null;
|
||||
const replayGainPeakCold = isReplayGainActive() ? (currentTrack.replayGainPeak ?? null) : null;
|
||||
const coldServerId = useAuthStore.getState().activeServerId ?? '';
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(currentTrack.id, coldServerId);
|
||||
@@ -1558,10 +2070,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(currentTrack.id),
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
analysisTrackId: currentTrack.id,
|
||||
}).catch((err: unknown) => {
|
||||
if (playGeneration !== gen) return;
|
||||
setDeferHotCachePrefetch(false);
|
||||
@@ -1851,6 +2365,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
...state.queue.slice(firstAutoIdx),
|
||||
];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
prefetchLoudnessForEnqueuedTracks(tracks, newQueue, state.queueIndex);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
},
|
||||
@@ -1897,6 +2412,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
? state.queueIndex + tracks.length
|
||||
: state.queueIndex;
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
prefetchLoudnessForEnqueuedTracks(tracks, newQueue, newQueueIndex);
|
||||
return { queue: newQueue, queueIndex: newQueueIndex };
|
||||
});
|
||||
},
|
||||
@@ -1988,12 +2504,22 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentTrack,
|
||||
currentTime: serverTime > 0 ? serverTime : localTime,
|
||||
});
|
||||
void refreshWaveformForTrack(currentTrack.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize queue from server', e);
|
||||
}
|
||||
},
|
||||
|
||||
reanalyzeLoudnessForTrack: async (trackId: string) => {
|
||||
try {
|
||||
showToast('Recalculating loudness for this track…', 2000, 'info');
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
await reseedLoudnessForTrackId(trackId);
|
||||
},
|
||||
|
||||
updateReplayGainForCurrentTrack: () => {
|
||||
const { currentTrack, queue, queueIndex, volume } = get();
|
||||
if (!currentTrack || !currentTrack.id) return;
|
||||
@@ -2002,16 +2528,28 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const next = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null;
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
currentTrack, prev, next,
|
||||
authState.replayGainEnabled, authState.replayGainMode,
|
||||
isReplayGainActive(), authState.replayGainMode,
|
||||
);
|
||||
const replayGainPeak = authState.replayGainEnabled
|
||||
const replayGainPeak = isReplayGainActive()
|
||||
? (currentTrack.replayGainPeak ?? null)
|
||||
: null;
|
||||
|
||||
invoke('audio_update_replay_gain', {
|
||||
const normalization = deriveNormalizationSnapshot(currentTrack, queue, queueIndex);
|
||||
const cachedLoud = cachedLoudnessGainByTrackId[currentTrack.id];
|
||||
const cachedLoudDb = Number.isFinite(cachedLoud) ? cachedLoud : null;
|
||||
set(prevState => ({
|
||||
normalizationNowDb:
|
||||
normalization.normalizationEngineLive === 'loudness'
|
||||
? (prevState.normalizationNowDb ?? cachedLoudDb)
|
||||
: normalization.normalizationNowDb,
|
||||
normalizationTargetLufs: normalization.normalizationTargetLufs,
|
||||
normalizationEngineLive: normalization.normalizationEngineLive,
|
||||
}));
|
||||
invoke('audio_update_replay_gain', {
|
||||
volume,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: currentTrack ? (cachedLoudnessGainByTrackId[currentTrack.id] ?? null) : null,
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
}).catch(console.error);
|
||||
|
||||
@@ -1989,6 +1989,24 @@ html[data-platform="windows"] .player-bar.floating {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.queue-current-tech-metric {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.queue-current-tech-metric:hover {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
.queue-divider {
|
||||
padding: var(--space-3) var(--space-4) 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
Reference in New Issue
Block a user