mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(playback): loudness bind rules, analysis seeding, and normalization UI
Resolve integrated loudness from SQLite only at decode bind; keep pre-trim until a row exists, then allow provisional gain from live updates. Pass DB-stable hints from the web app into play and gapless preload. Default pre-measurement attenuation -4.5 dB with an icon reset in Settings; drop redundant normalization copy and shorten pre-trim descriptions. analysis_cache seed_from_bytes returns an outcome and skips redundant waveform work on cache hits; wire callers and related frontend/backend glue.
This commit is contained in:
+104
-47
@@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ebur128::{EbuR128, Mode as Ebur128Mode};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
@@ -315,51 +315,122 @@ pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_
|
||||
recommended_gain_db.clamp(-24.0, 24.0)
|
||||
}
|
||||
|
||||
pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
/// 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 {
|
||||
return Ok(());
|
||||
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),
|
||||
};
|
||||
cache.touch_track_status(&key, "queued")?;
|
||||
|
||||
let wf_bins: Vec<u8>;
|
||||
let loudness_opt: Option<(f64, f64, f64, f64)>;
|
||||
match analyze_loudness_and_waveform(bytes, -16.0, 500) {
|
||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
|
||||
wf_bins = bins;
|
||||
loudness_opt = Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs));
|
||||
}
|
||||
None => {
|
||||
wf_bins = derive_waveform_bins(bytes, 500);
|
||||
loudness_opt = None;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
crate::app_deprintln!(
|
||||
"[analysis][waveform] build start track_id={} bytes={} md5_16kb={}",
|
||||
track_id,
|
||||
bytes.len(),
|
||||
key.md5_16kb
|
||||
);
|
||||
|
||||
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,
|
||||
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_loudness(&key, &loudness)?;
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cache.touch_track_status(&key, "ready")?;
|
||||
Ok(())
|
||||
match build {
|
||||
Ok(_) => Ok(SeedFromBytesOutcome::Upserted),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_ts() -> i64 {
|
||||
@@ -399,20 +470,6 @@ struct PcmScanResult {
|
||||
loudness: Option<(f64, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
/// Decoded PCM peak envelope (mono mix) → `bin_count` bars, for seekbar display.
|
||||
/// Two decode passes: count frames, then fill bins. Returns `None` if probing/decoding fails.
|
||||
pub fn derive_waveform_bins_from_audio_bytes(bytes: &[u8], bin_count: usize) -> Option<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, None)?;
|
||||
Some(scanned.bins)
|
||||
}
|
||||
|
||||
/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count.
|
||||
fn analyze_loudness_and_waveform(
|
||||
bytes: &[u8],
|
||||
|
||||
+290
-217
@@ -22,16 +22,6 @@ use symphonia::core::{
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PartialWaveformPayload {
|
||||
track_id: Option<String>,
|
||||
bins: Vec<u8>,
|
||||
known_until_sec: f64,
|
||||
duration_sec: f64,
|
||||
is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PartialLoudnessPayload {
|
||||
@@ -1062,6 +1052,8 @@ async fn track_download_task(
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
cache_track_id: Option<String>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
@@ -1161,7 +1153,11 @@ async fn track_download_task(
|
||||
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
||||
{
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs);
|
||||
let pre_db = f32::from_bits(
|
||||
loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed),
|
||||
)
|
||||
.clamp(-24.0, 0.0);
|
||||
emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs, pre_db);
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
}
|
||||
offset += pushed;
|
||||
@@ -1170,14 +1166,19 @@ async fn track_download_task(
|
||||
}
|
||||
}
|
||||
if !capture_over_limit && !capture.is_empty() {
|
||||
if let Some(track_id) = playback_identity(&url) {
|
||||
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) {
|
||||
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload { track_id, is_partial: false },
|
||||
);
|
||||
if let Some(track_id) = cache_track_id {
|
||||
match crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) {
|
||||
Err(e) => crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e),
|
||||
Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: track_id.clone(),
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||
@@ -1199,7 +1200,7 @@ async fn ranged_download_task(
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
app: AppHandle,
|
||||
duration_hint: f64,
|
||||
_duration_hint: f64,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
@@ -1207,6 +1208,8 @@ async fn ranged_download_task(
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
cache_track_id: Option<String>,
|
||||
) {
|
||||
let total_size = buf.lock().unwrap().len();
|
||||
let mut downloaded: usize = 0;
|
||||
@@ -1214,8 +1217,6 @@ async fn ranged_download_task(
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let dl_started = Instant::now();
|
||||
let mut next_progress_mb: usize = 1;
|
||||
let mut last_partial_emit = Instant::now();
|
||||
let mut last_partial_emit_downloaded: usize = 0;
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
|
||||
'outer: loop {
|
||||
@@ -1291,55 +1292,27 @@ async fn ranged_download_task(
|
||||
}
|
||||
downloaded += n;
|
||||
downloaded_to.store(downloaded, Ordering::SeqCst);
|
||||
if downloaded >= 4096
|
||||
if downloaded >= PARTIAL_LOUDNESS_MIN_BYTES
|
||||
&& total_size > 0
|
||||
&& downloaded.saturating_sub(last_partial_emit_downloaded) >= PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA
|
||||
&& last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded)
|
||||
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
||||
{
|
||||
// Keep streaming path lightweight: avoid expensive PCM decode
|
||||
// in the hot loop (it can steal CPU and cause audible underruns
|
||||
// while playback is starting). We emit a fast sampled waveform
|
||||
// here and let full analysis/backfill produce final bins later.
|
||||
let bins = derive_partial_waveform_bins_short_locks(&buf, downloaded, 500);
|
||||
if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) {
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) {
|
||||
let _ = app.emit(
|
||||
"analysis:loudness-partial",
|
||||
PartialLoudnessPayload {
|
||||
track_id: playback_identity(&url),
|
||||
gain_db: provisional_db,
|
||||
target_lufs,
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness provisional progress={:.2}% gain_db={:.2} target_lufs={:.2} track_id={:?}",
|
||||
(downloaded as f32 / total_size as f32) * 100.0,
|
||||
provisional_db,
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let start_db = f32::from_bits(loudness_pre_analysis_attenuation_db.load(Ordering::Relaxed))
|
||||
.clamp(-24.0, 0.0);
|
||||
if let Some(provisional_db) =
|
||||
provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs, start_db)
|
||||
{
|
||||
let _ = app.emit(
|
||||
"analysis:loudness-partial",
|
||||
PartialLoudnessPayload {
|
||||
track_id: playback_identity(&url),
|
||||
gain_db: provisional_db,
|
||||
target_lufs,
|
||||
playback_identity(&url)
|
||||
);
|
||||
}
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
};
|
||||
let known_until_sec = if duration_hint > 0.0 {
|
||||
(duration_hint * downloaded as f64 / total_size as f64).clamp(0.0, duration_hint)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-partial",
|
||||
PartialWaveformPayload {
|
||||
track_id: playback_identity(&url),
|
||||
bins,
|
||||
known_until_sec,
|
||||
duration_sec: duration_hint.max(0.0),
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
last_partial_emit = Instant::now();
|
||||
last_partial_emit_downloaded = downloaded;
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
}
|
||||
let mb = downloaded / (1024 * 1024);
|
||||
if mb >= next_progress_mb {
|
||||
@@ -1372,14 +1345,19 @@ async fn ranged_download_task(
|
||||
|
||||
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
let data = buf.lock().unwrap().clone();
|
||||
if let Some(track_id) = playback_identity(&url) {
|
||||
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
|
||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload { track_id, is_partial: false },
|
||||
);
|
||||
if let Some(track_id) = cache_track_id {
|
||||
match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
|
||||
Err(e) => crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e),
|
||||
Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: track_id.clone(),
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
@@ -1387,86 +1365,13 @@ async fn ranged_download_task(
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall-clock spacing for `analysis:waveform-partial` — larger buffers cost more
|
||||
/// to summarize, so we slow emits and keep UI responsive without CPU spikes.
|
||||
fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration {
|
||||
const MB: usize = 1024 * 1024;
|
||||
if downloaded <= 3 * MB {
|
||||
Duration::from_millis(260)
|
||||
} else if downloaded <= 10 * MB {
|
||||
Duration::from_millis(620)
|
||||
} else {
|
||||
Duration::from_millis(980)
|
||||
}
|
||||
}
|
||||
|
||||
/// Max centered-byte samples examined per bin for partial waveforms (full track
|
||||
/// analysis still uses dense scans elsewhere). Keeps work O(bin_count × cap).
|
||||
const PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP: usize = 2048;
|
||||
|
||||
fn peak_centered_byte_sampled(region: &[u8]) -> u8 {
|
||||
if region.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let cap = PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP;
|
||||
let mut peak: u8 = 0;
|
||||
if region.len() <= cap {
|
||||
for &b in region {
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let step = (region.len() / cap).max(1);
|
||||
let mut i = 0;
|
||||
while i < region.len() {
|
||||
let b = region[i];
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
i = i.saturating_add(step);
|
||||
}
|
||||
let b = region[region.len() - 1];
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
}
|
||||
peak
|
||||
}
|
||||
|
||||
/// Partial waveform without cloning the whole download buffer and without
|
||||
/// holding `buf` locked across all bins (that would stall the decoder's `read()`).
|
||||
fn derive_partial_waveform_bins_short_locks(
|
||||
buf: &Arc<Mutex<Vec<u8>>>,
|
||||
downloaded: usize,
|
||||
bin_count: usize,
|
||||
) -> Vec<u8> {
|
||||
if downloaded == 0 || bin_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let len = downloaded;
|
||||
let mut out = vec![0u8; bin_count];
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
let start = i * len / bin_count;
|
||||
let end = ((i + 1) * len / bin_count).max(start + 1).min(len);
|
||||
let peak = {
|
||||
let b = buf.lock().unwrap();
|
||||
if start >= b.len() {
|
||||
0u8
|
||||
} else {
|
||||
let end = end.min(b.len());
|
||||
peak_centered_byte_sampled(&b[start..end])
|
||||
}
|
||||
};
|
||||
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], target_lufs: f32) {
|
||||
fn emit_partial_loudness_from_bytes(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
bytes: &[u8],
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
) {
|
||||
if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}",
|
||||
@@ -1477,7 +1382,17 @@ fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], ta
|
||||
}
|
||||
// Lightweight fallback based on buffered bytes count to keep CPU low.
|
||||
let mb = bytes.len() as f32 / (1024.0 * 1024.0);
|
||||
let floor_db = (target_lufs + 11.0).clamp(-6.0, -1.5);
|
||||
let pre_floor = pre_analysis_attenuation_db.clamp(-24.0, 0.0);
|
||||
// Target-derived hint (e.g. -12 LUFS → -1 dB). Old `(hint).clamp(pre, 0)` left
|
||||
// the hint when it lay inside [pre, 0] — e.g. -1 with pre=-6, so AAC/M4A
|
||||
// streaming often sat at -1 dB until full analysis. Combine with user trim:
|
||||
// stricter (more negative) pre wins; milder pre still caps vs the hint.
|
||||
let heuristic_floor = (target_lufs + 11.0).clamp(-6.0, 0.0);
|
||||
let floor_db = if pre_floor < heuristic_floor {
|
||||
pre_floor
|
||||
} else {
|
||||
pre_floor.max(heuristic_floor)
|
||||
};
|
||||
let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}",
|
||||
@@ -1497,14 +1412,19 @@ fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], ta
|
||||
);
|
||||
}
|
||||
|
||||
fn provisional_loudness_gain_from_progress(downloaded: usize, total_size: usize, target_lufs: f32) -> Option<f32> {
|
||||
fn provisional_loudness_gain_from_progress(
|
||||
downloaded: usize,
|
||||
total_size: usize,
|
||||
target_lufs: f32,
|
||||
start_db_in: f32,
|
||||
) -> Option<f32> {
|
||||
if total_size == 0 || downloaded == 0 {
|
||||
return None;
|
||||
}
|
||||
let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0);
|
||||
// Move from startup attenuation toward a more realistic late-stream level.
|
||||
// This avoids staying near -2 dB and then jumping hard when final LUFS lands.
|
||||
let start_db = LOUDNESS_STARTUP_ATTENUATION_DB.min(0.0);
|
||||
let start_db = start_db_in.clamp(-24.0, 0.0).min(0.0);
|
||||
let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0);
|
||||
let shaped = progress.powf(0.75);
|
||||
Some(start_db + (end_db - start_db) * shaped)
|
||||
@@ -2246,6 +2166,8 @@ pub struct AudioEngine {
|
||||
pub normalization_engine: Arc<AtomicU32>,
|
||||
/// Target loudness in LUFS for loudness engine (future use).
|
||||
pub normalization_target_lufs: Arc<AtomicU32>,
|
||||
/// Extra attenuation (dB) when no loudness DB row exists at decode bind; also seeds streaming heuristics (Settings).
|
||||
pub loudness_pre_analysis_attenuation_db: Arc<AtomicU32>,
|
||||
/// Info about the next-up chained track (gapless mode).
|
||||
/// The progress task reads this when `current_source_done` fires.
|
||||
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
@@ -2266,6 +2188,9 @@ pub struct AudioEngine {
|
||||
/// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null`
|
||||
/// (otherwise `compute_gain` would treat that as unity gain and playback "jumps").
|
||||
pub(crate) current_playback_url: Arc<Mutex<Option<String>>>,
|
||||
/// Subsonic song id last passed from JS with `audio_play` (trimmed). Used
|
||||
/// for loudness/waveform cache when the URL is `psysonic-local://…`.
|
||||
pub(crate) current_analysis_track_id: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
@@ -2531,6 +2456,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())),
|
||||
loudness_pre_analysis_attenuation_db: Arc::new(AtomicU32::new((-4.5f32).to_bits())),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
@@ -2538,6 +2464,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
radio_state: Mutex::new(None),
|
||||
current_playback_url: Arc::new(Mutex::new(None)),
|
||||
current_analysis_track_id: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
@@ -2592,6 +2519,18 @@ fn playback_identity(url: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Stable id for analysis cache rows and `analysis:waveform-updated`.
|
||||
/// Prefer the Subsonic track id from the frontend: `psysonic-local://` URLs
|
||||
/// only map to `local:path` in `playback_identity`, which does not match
|
||||
/// `analysis_get_waveform_for_track(trackId)` or the UI's `currentTrack.id`.
|
||||
fn analysis_cache_track_id(logical_track_id: Option<&str>, url: &str) -> Option<String> {
|
||||
let logical = logical_track_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
logical.or_else(|| playback_identity(url))
|
||||
}
|
||||
|
||||
fn same_playback_target(a_url: &str, b_url: &str) -> bool {
|
||||
match (playback_identity(a_url), playback_identity(b_url)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
@@ -2603,30 +2542,23 @@ fn resolve_loudness_gain_from_cache(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
target_lufs: f32,
|
||||
requested_loudness_gain_db: Option<f32>,
|
||||
logical_track_id: Option<&str>,
|
||||
) -> Option<f32> {
|
||||
// Never trust `requested` alone: the frontend may pass the *next* track's gain
|
||||
// while `current_playback_url` still lags one play behind. Always prefer a
|
||||
// cache row for **this** URL's track_id; use `requested` only on cache miss
|
||||
// (provisional / pre-seed from JS).
|
||||
let Some(track_id) = playback_identity(url) else {
|
||||
if let Some(r) = requested_loudness_gain_db {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=request-no-identity arg={:.4}",
|
||||
r
|
||||
);
|
||||
}
|
||||
return requested_loudness_gain_db;
|
||||
// Only a SQLite loudness row counts here. Ephemeral JS hints (`analysis:loudness-partial`)
|
||||
// are applied in `audio_update_replay_gain` via `loudness_gain_db_or_startup(..., true, _)`.
|
||||
let Some(track_id) = analysis_cache_track_id(logical_track_id, url) else {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=no-identity url_len={}",
|
||||
url.len()
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let Some(cache) = app.try_state::<crate::analysis_cache::AnalysisCache>() else {
|
||||
if let Some(r) = requested_loudness_gain_db {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=request-no-cache arg={:.4} track_id={}",
|
||||
r,
|
||||
track_id
|
||||
);
|
||||
}
|
||||
return requested_loudness_gain_db;
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=no-analysis-cache track_id={}",
|
||||
track_id
|
||||
);
|
||||
return None;
|
||||
};
|
||||
// Also touch waveform row here so playback path verifies current context is present.
|
||||
let _ = cache.get_latest_waveform_for_track(&track_id);
|
||||
@@ -2660,16 +2592,7 @@ fn resolve_loudness_gain_from_cache(
|
||||
"[normalization] resolve_loudness_gain source=cache-miss track_id={}",
|
||||
track_id
|
||||
);
|
||||
if let Some(r) = requested_loudness_gain_db {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=request-fallback track_id={} arg={:.4}",
|
||||
track_id,
|
||||
r
|
||||
);
|
||||
Some(r)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
@@ -2682,16 +2605,43 @@ fn resolve_loudness_gain_from_cache(
|
||||
}
|
||||
}
|
||||
|
||||
/// LUFS mode: use cache / explicit `requested`, else a **conservative** trim until
|
||||
/// analysis exists — must never return `None` here or `compute_gain` uses unity.
|
||||
/// LUFS: DB-backed integrated LUFS only at bind time (`allow_js_when_uncached = false`);
|
||||
/// after `analysis:loudness-partial`, `audio_update_replay_gain` passes `true` so finite
|
||||
/// JS gain applies until SQLite catches up. Must never return `None` or `compute_gain` uses unity.
|
||||
fn loudness_gain_db_or_startup(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
target_lufs: f32,
|
||||
requested: Option<f32>,
|
||||
logical_track_id: Option<&str>,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
allow_js_when_uncached: bool,
|
||||
js_gain_db: Option<f32>,
|
||||
) -> Option<f32> {
|
||||
resolve_loudness_gain_from_cache(app, url, target_lufs, requested)
|
||||
.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB))
|
||||
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
|
||||
match resolve_loudness_gain_from_cache(app, url, target_lufs, logical_track_id) {
|
||||
Some(g) => Some(g),
|
||||
None => {
|
||||
if allow_js_when_uncached {
|
||||
match js_gain_db {
|
||||
Some(r) if r.is_finite() => Some(r),
|
||||
_ => Some(pre),
|
||||
}
|
||||
} else {
|
||||
Some(pre)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn loudness_pre_analysis_db_for_engine(state: &AudioEngine) -> f32 {
|
||||
f32::from_bits(
|
||||
state
|
||||
.loudness_pre_analysis_attenuation_db
|
||||
.load(Ordering::Relaxed),
|
||||
)
|
||||
.clamp(-24.0, 0.0)
|
||||
.min(0.0)
|
||||
}
|
||||
|
||||
/// Take (consume) completed manual-stream bytes if they correspond to `url`.
|
||||
@@ -2792,10 +2742,6 @@ async fn fetch_data(
|
||||
const MASTER_HEADROOM: f32 = 0.891_254;
|
||||
const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024;
|
||||
const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 900;
|
||||
const PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA: usize = 192 * 1024;
|
||||
/// Until integrated LUFS is known, stay clearly below "full" level so a follow-up
|
||||
/// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor.
|
||||
const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0;
|
||||
|
||||
fn compute_gain(
|
||||
normalization_engine: u32,
|
||||
@@ -2891,6 +2837,9 @@ fn ramp_sink_volume(sink: Arc<Sink>, from: f32, to: f32) {
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `analysis_track_id`: Subsonic `song.id` from the UI — ties waveform/loudness
|
||||
/// cache to the track when playing `psysonic-local://` (hot/offline). Optional
|
||||
/// for HTTP streams (`playback_identity` is used as fallback).
|
||||
#[tauri::command]
|
||||
pub async fn audio_play(
|
||||
url: String,
|
||||
@@ -2903,6 +2852,7 @@ pub async fn audio_play(
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
analysis_track_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -2979,6 +2929,12 @@ pub async fn audio_play(
|
||||
// a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not
|
||||
// the previous URL still stored until the sink swap completes.
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
*state.current_analysis_track_id.lock().unwrap() = logical_trim.clone();
|
||||
let cache_id_for_tasks = analysis_cache_track_id(logical_trim.as_deref(), &url);
|
||||
|
||||
// Extract format hint from URL for better symphonia probing. Strip the
|
||||
// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||
@@ -3053,6 +3009,45 @@ pub async fn audio_play(
|
||||
len / 1024,
|
||||
local_hint
|
||||
);
|
||||
if let Some(ref seed_id) = cache_id_for_tasks {
|
||||
let path_owned = std::path::PathBuf::from(path);
|
||||
let app_seed = app.clone();
|
||||
let gen_seed = gen;
|
||||
let gen_arc_seed = state.generation.clone();
|
||||
let seed_id = seed_id.clone();
|
||||
tokio::spawn(async move {
|
||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
||||
return;
|
||||
}
|
||||
let data = match tokio::fs::read(&path_owned).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
};
|
||||
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
|
||||
return;
|
||||
}
|
||||
if data.is_empty() || data.len() > TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
match crate::analysis_cache::seed_from_bytes(&app_seed, &seed_id, &data) {
|
||||
Err(e) => crate::app_eprintln!(
|
||||
"[analysis] local-file seed failed for {}: {}",
|
||||
seed_id,
|
||||
e
|
||||
),
|
||||
Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => {
|
||||
let _ = app_seed.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: seed_id.clone(),
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
let reader = LocalFileSource { file, len };
|
||||
PlayInput::SeekableMedia {
|
||||
reader: Box::new(reader),
|
||||
@@ -3112,6 +3107,8 @@ pub async fn audio_play(
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
cache_id_for_tasks.clone(),
|
||||
));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
@@ -3150,6 +3147,8 @@ pub async fn audio_play(
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||
cache_id_for_tasks.clone(),
|
||||
));
|
||||
|
||||
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
||||
@@ -3184,10 +3183,24 @@ pub async fn audio_play(
|
||||
}
|
||||
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let resolved_loudness_gain_db = resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db);
|
||||
let resolved_loudness_gain_db = resolve_loudness_gain_from_cache(
|
||||
&app,
|
||||
&url,
|
||||
target_lufs,
|
||||
logical_trim.as_deref(),
|
||||
);
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
|
||||
let startup_loudness_gain_db = if norm_mode == 2 {
|
||||
loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db)
|
||||
loudness_gain_db_or_startup(
|
||||
&app,
|
||||
&url,
|
||||
target_lufs,
|
||||
logical_trim.as_deref(),
|
||||
pre_analysis_db,
|
||||
false,
|
||||
loudness_gain_db,
|
||||
)
|
||||
} else {
|
||||
resolved_loudness_gain_db
|
||||
};
|
||||
@@ -3511,6 +3524,7 @@ pub async fn audio_chain_preload(
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
analysis_track_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -3571,6 +3585,11 @@ pub async fn audio_chain_preload(
|
||||
|
||||
let raw_bytes = Arc::new(data);
|
||||
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
|
||||
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
|
||||
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
|
||||
@@ -3578,10 +3597,19 @@ pub async fn audio_chain_preload(
|
||||
// applied at the gapless transition in `spawn_progress_task`, not here.
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
|
||||
let chain_loudness_db = if norm_mode == 2 {
|
||||
loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db)
|
||||
loudness_gain_db_or_startup(
|
||||
&app,
|
||||
&url,
|
||||
target_lufs,
|
||||
logical_trim.as_deref(),
|
||||
pre_analysis_db,
|
||||
false,
|
||||
loudness_gain_db,
|
||||
)
|
||||
} else {
|
||||
resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db)
|
||||
resolve_loudness_gain_from_cache(&app, &url, target_lufs, logical_trim.as_deref())
|
||||
};
|
||||
let (gain_linear, _effective_volume) = compute_gain(
|
||||
norm_mode,
|
||||
@@ -3940,6 +3968,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.current_playback_url.lock().unwrap() = None;
|
||||
*state.current_analysis_track_id.lock().unwrap() = None;
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
*state.stream_completed_cache.lock().unwrap() = None;
|
||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||
@@ -4075,22 +4104,45 @@ pub fn audio_update_replay_gain(
|
||||
) {
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let pre_analysis_db = loudness_pre_analysis_db_for_engine(&state);
|
||||
let url_for_loudness = if norm_mode == 2 {
|
||||
state.current_playback_url.lock().unwrap().clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let logical_for_loudness = state
|
||||
.current_analysis_track_id
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|g| (*g).clone())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
// If `current_playback_url` is not pinned yet, still honour JS `loudness_gain_db`
|
||||
// so `loudness_ui_current_gain_db` can show a number (otherwise `and_then`
|
||||
// drops the requested gain entirely).
|
||||
let resolved_loudness_gain_db = url_for_loudness
|
||||
.as_deref()
|
||||
.and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db))
|
||||
.and_then(|u| {
|
||||
resolve_loudness_gain_from_cache(
|
||||
&app,
|
||||
u,
|
||||
target_lufs,
|
||||
logical_for_loudness.as_deref(),
|
||||
)
|
||||
})
|
||||
.or(loudness_gain_db);
|
||||
let effective_loudness_db = if norm_mode == 2 {
|
||||
match url_for_loudness.as_deref() {
|
||||
Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db),
|
||||
None => loudness_gain_db.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)),
|
||||
Some(u) => loudness_gain_db_or_startup(
|
||||
&app,
|
||||
u,
|
||||
target_lufs,
|
||||
logical_for_loudness.as_deref(),
|
||||
pre_analysis_db,
|
||||
true,
|
||||
loudness_gain_db,
|
||||
),
|
||||
None => loudness_gain_db.or(Some(pre_analysis_db)),
|
||||
}
|
||||
} else {
|
||||
loudness_gain_db
|
||||
@@ -4193,6 +4245,7 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State
|
||||
pub async fn audio_preload(
|
||||
url: String,
|
||||
duration_hint: f64,
|
||||
analysis_track_id: Option<String>,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
@@ -4221,14 +4274,23 @@ pub async fn audio_preload(
|
||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
};
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
if let Some(track_id) = playback_identity(&url) {
|
||||
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
|
||||
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload { track_id, is_partial: false },
|
||||
);
|
||||
let logical_trim = analysis_track_id
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
|
||||
match crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
|
||||
Err(e) => crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e),
|
||||
Ok(crate::analysis_cache::SeedFromBytesOutcome::Upserted) => {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: track_id.clone(),
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
let url_for_emit = url.clone();
|
||||
@@ -4579,7 +4641,13 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
pub fn audio_set_normalization(
|
||||
engine: String,
|
||||
target_lufs: f32,
|
||||
pre_analysis_attenuation_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let mode = match engine.as_str() {
|
||||
"replaygain" => 1,
|
||||
"loudness" => 2,
|
||||
@@ -4590,11 +4658,16 @@ pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle,
|
||||
state
|
||||
.normalization_target_lufs
|
||||
.store(target.to_bits(), Ordering::Relaxed);
|
||||
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
|
||||
state
|
||||
.loudness_pre_analysis_attenuation_db
|
||||
.store(pre.to_bits(), Ordering::Relaxed);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2}",
|
||||
"[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2} pre_analysis_db={:.2}",
|
||||
engine,
|
||||
normalization_engine_name(mode),
|
||||
target
|
||||
target,
|
||||
pre
|
||||
);
|
||||
let _ = app.emit(
|
||||
"audio:normalization-state",
|
||||
|
||||
+53
-13
@@ -1199,8 +1199,30 @@ fn analysis_get_waveform(
|
||||
md5_16kb: String,
|
||||
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||
) -> Result<Option<WaveformCachePayload>, String> {
|
||||
let key = analysis_cache::TrackKey { track_id, md5_16kb };
|
||||
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,
|
||||
@@ -1217,6 +1239,23 @@ fn analysis_get_waveform_for_track(
|
||||
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,
|
||||
@@ -1349,18 +1388,19 @@ async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path
|
||||
}
|
||||
|
||||
fn enqueue_analysis_seed(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> Result<bool, String> {
|
||||
analysis_cache::seed_from_bytes(app, track_id, bytes)
|
||||
.map_err(|e| {
|
||||
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
|
||||
e
|
||||
})?;
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload {
|
||||
track_id: track_id.to_string(),
|
||||
is_partial: false,
|
||||
},
|
||||
);
|
||||
let 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())
|
||||
|
||||
@@ -109,6 +109,24 @@ function easeOutCubic(t: number): number {
|
||||
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);
|
||||
@@ -865,9 +883,6 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
|
||||
const seek = usePlayerStore(s => s.seek);
|
||||
const waveformBins = usePlayerStore(s => s.waveformBins);
|
||||
const waveformIsPartial = usePlayerStore(s => s.waveformIsPartial);
|
||||
const waveformKnownUntilSec = usePlayerStore(s => s.waveformKnownUntilSec);
|
||||
const waveformDurationSec = usePlayerStore(s => s.waveformDurationSec);
|
||||
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
|
||||
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
|
||||
|
||||
@@ -882,18 +897,16 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
return;
|
||||
}
|
||||
if (waveformBins && waveformBins.length > 0) {
|
||||
const src = waveformBins;
|
||||
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)));
|
||||
}
|
||||
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();
|
||||
@@ -946,7 +959,7 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
}
|
||||
// No analysis bins yet: render 500 flat bars immediately.
|
||||
heightsRef.current = makeFlatWaveHeights();
|
||||
}, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]);
|
||||
}, [trackId, waveformBins]);
|
||||
|
||||
// Imperative subscription — no React re-renders from progress changes.
|
||||
// Static styles draw here; animated styles only update refs.
|
||||
@@ -983,9 +996,6 @@ export default function WaveformSeek({ trackId }: Props) {
|
||||
seekbarStyle,
|
||||
trackId,
|
||||
waveformBins,
|
||||
waveformIsPartial,
|
||||
waveformKnownUntilSec,
|
||||
waveformDurationSec,
|
||||
duration,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -853,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',
|
||||
|
||||
@@ -886,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 />
|
||||
|
||||
+78
-45
@@ -26,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';
|
||||
@@ -65,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',
|
||||
@@ -1795,9 +1828,6 @@ export default function Settings() {
|
||||
const deleted = await invoke<number>('analysis_delete_all_waveforms');
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
waveformDurationSec: 0,
|
||||
});
|
||||
showToast(
|
||||
t('settings.waveformCacheCleared', { count: deleted }),
|
||||
@@ -2243,12 +2273,7 @@ export default function Settings() {
|
||||
<div className="settings-card">
|
||||
{/* Normalization */}
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.normalizationDesc', { defaultValue: 'Choose one normalization mode: ReplayGain or Loudness (LUFS).' })}
|
||||
</div>
|
||||
</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'}`}
|
||||
@@ -2283,43 +2308,51 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{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 === 'loudness' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Target LUFS:</span>
|
||||
<button
|
||||
className={`btn ${auth.loudnessTargetLufs === -10 ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setLoudnessTargetLufs(-10)}
|
||||
>
|
||||
-10
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.loudnessTargetLufs === -12 ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setLoudnessTargetLufs(-12)}
|
||||
>
|
||||
-12
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.loudnessTargetLufs === -14 ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setLoudnessTargetLufs(-14)}
|
||||
>
|
||||
-14
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${auth.loudnessTargetLufs === -16 ? 'btn-primary' : 'btn-ghost'}`}
|
||||
style={{ fontSize: 12, padding: '3px 12px' }}
|
||||
onClick={() => auth.setLoudnessTargetLufs(-16)}
|
||||
>
|
||||
-16
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{auth.normalizationEngine === 'replaygain' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.replayGainMode')}:</span>
|
||||
|
||||
+41
-2
@@ -22,6 +22,27 @@ export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented
|
||||
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; }
|
||||
|
||||
@@ -51,7 +72,9 @@ interface AuthState {
|
||||
customGenreBlacklist: string[];
|
||||
replayGainEnabled: boolean;
|
||||
normalizationEngine: NormalizationEngine;
|
||||
loudnessTargetLufs: -16 | -14 | -12 | -10;
|
||||
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)
|
||||
@@ -212,7 +235,9 @@ interface AuthState {
|
||||
setCustomGenreBlacklist: (v: string[]) => void;
|
||||
setReplayGainEnabled: (v: boolean) => void;
|
||||
setNormalizationEngine: (v: NormalizationEngine) => void;
|
||||
setLoudnessTargetLufs: (v: -16 | -14 | -12 | -10) => 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;
|
||||
@@ -326,6 +351,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
replayGainEnabled: false,
|
||||
normalizationEngine: 'off',
|
||||
loudnessTargetLufs: -12,
|
||||
loudnessPreAnalysisAttenuationDb: DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB,
|
||||
replayGainMode: 'auto',
|
||||
replayGainPreGainDb: 0,
|
||||
replayGainFallbackDb: 0,
|
||||
@@ -462,6 +488,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
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();
|
||||
@@ -696,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),
|
||||
@@ -703,6 +740,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
|
||||
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
|
||||
),
|
||||
loudnessTargetLufs: targetSan,
|
||||
loudnessPreAnalysisAttenuationDb: preSan,
|
||||
...conflictingLegacyState,
|
||||
...lyricsSourcesMigrated,
|
||||
...wheelSmoothOneTime,
|
||||
|
||||
+95
-87
@@ -148,9 +148,6 @@ async function buildInfiniteQueueCandidates(
|
||||
interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
waveformBins: number[] | null;
|
||||
waveformIsPartial: boolean;
|
||||
waveformKnownUntilSec: number;
|
||||
waveformDurationSec: number;
|
||||
normalizationNowDb: number | null;
|
||||
normalizationTargetLufs: number | null;
|
||||
normalizationEngineLive: 'off' | 'replaygain' | 'loudness';
|
||||
@@ -264,7 +261,8 @@ interface PlayerState {
|
||||
}
|
||||
|
||||
type WaveformCachePayload = {
|
||||
bins: number[];
|
||||
/** May be `number[]` or `Uint8Array` depending on Tauri IPC / serde path. */
|
||||
bins: number[] | Uint8Array;
|
||||
binCount: number;
|
||||
isPartial: boolean;
|
||||
knownUntilSec: number;
|
||||
@@ -272,6 +270,27 @@ type WaveformCachePayload = {
|
||||
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;
|
||||
@@ -613,6 +632,14 @@ function clearLoudnessCacheStateForTrackId(trackId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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];
|
||||
@@ -651,20 +678,15 @@ async function refreshWaveformForTrack(trackId: string) {
|
||||
if (!trackId) return;
|
||||
try {
|
||||
const row = await invoke<WaveformCachePayload | null>('analysis_get_waveform_for_track', { trackId });
|
||||
if (!row || !Array.isArray(row.bins) || row.bins.length === 0) {
|
||||
const bins = row ? coerceWaveformBins(row.bins) : null;
|
||||
if (!bins || bins.length === 0) {
|
||||
usePlayerStore.setState({
|
||||
waveformBins: null,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
waveformDurationSec: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
usePlayerStore.setState({
|
||||
waveformBins: row.bins,
|
||||
waveformIsPartial: !!row.isPartial,
|
||||
waveformKnownUntilSec: Number.isFinite(row.knownUntilSec) ? row.knownUntilSec : 0,
|
||||
waveformDurationSec: Number.isFinite(row.durationSec) ? row.durationSec : 0,
|
||||
waveformBins: bins,
|
||||
});
|
||||
} catch {
|
||||
// best-effort; seekbar falls back to placeholder waveform
|
||||
@@ -738,6 +760,31 @@ async function refreshLoudnessForTrack(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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>(
|
||||
@@ -824,10 +871,6 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
const dur = duration > 0 ? duration : track.duration;
|
||||
if (dur <= 0) return;
|
||||
const progress = displayTime / dur;
|
||||
// Do not couple `waveformKnownUntilSec` to playhead: for partial streams it
|
||||
// must track **download/analysis progress** (emitted in `analysis:waveform-partial`).
|
||||
// Using `displayTime` here broke the seekbar after seeks — bins only cover the
|
||||
// buffered prefix of the file while `knownUntil` jumped with the seek position.
|
||||
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||||
|
||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||
@@ -915,7 +958,11 @@ 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.
|
||||
@@ -942,10 +989,11 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
durationHint: nextTrack.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: cachedLoudnessGainByTrackId[nextTrack.id] ?? null,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: nextTrack.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -972,8 +1020,6 @@ function handleAudioEnded() {
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
buffered: 0,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (repeatMode === 'one' && currentTrack) {
|
||||
@@ -1019,9 +1065,6 @@ function handleAudioTrackSwitched(duration: number) {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: nextTrack,
|
||||
waveformBins: null,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
waveformDurationSec: 0,
|
||||
...deriveNormalizationSnapshot(nextTrack, queue, newIndex),
|
||||
normalizationDbgSource: 'track-switched',
|
||||
normalizationDbgTrackId: nextTrack.id,
|
||||
@@ -1106,48 +1149,6 @@ export function initAudioListeners(): () => void {
|
||||
listen<void>('audio:ended', () => handleAudioEnded()),
|
||||
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
|
||||
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
|
||||
listen<{ trackId?: string | null; bins: number[]; knownUntilSec: number; durationSec: number; isPartial: boolean }>('analysis:waveform-partial', ({ payload }) => {
|
||||
const current = usePlayerStore.getState().currentTrack;
|
||||
if (!current || !payload) return;
|
||||
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
|
||||
if (payloadTrackId && payloadTrackId !== current.id) return;
|
||||
if (!payload.isPartial || !payload?.bins?.length) {
|
||||
usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 });
|
||||
return;
|
||||
}
|
||||
const nextKnownUntilSec = Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0;
|
||||
const nextDurationSec = Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0);
|
||||
usePlayerStore.setState((prev) => {
|
||||
const prevBins = prev.waveformBins;
|
||||
const prevKnownUntilSec = Number.isFinite(prev.waveformKnownUntilSec) ? prev.waveformKnownUntilSec : 0;
|
||||
if (!prevBins || prevBins.length === 0 || prevKnownUntilSec <= 0 || nextDurationSec <= 0) {
|
||||
return {
|
||||
waveformBins: payload.bins,
|
||||
waveformIsPartial: true,
|
||||
waveformKnownUntilSec: nextKnownUntilSec,
|
||||
waveformDurationSec: nextDurationSec,
|
||||
};
|
||||
}
|
||||
const len = Math.max(prevBins.length, payload.bins.length);
|
||||
const merged = new Array<number>(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
merged[i] = i < prevBins.length ? prevBins[i] : 0;
|
||||
}
|
||||
const prevKnownBars = Math.max(0, Math.min(len, Math.floor((prevKnownUntilSec / nextDurationSec) * len)));
|
||||
const nextKnownBars = Math.max(0, Math.min(len, Math.floor((nextKnownUntilSec / nextDurationSec) * len)));
|
||||
if (nextKnownBars > prevKnownBars) {
|
||||
for (let i = prevKnownBars; i < nextKnownBars; i++) {
|
||||
if (i < payload.bins.length) merged[i] = payload.bins[i];
|
||||
}
|
||||
}
|
||||
return {
|
||||
waveformBins: merged,
|
||||
waveformIsPartial: true,
|
||||
waveformKnownUntilSec: Math.max(prevKnownUntilSec, nextKnownUntilSec),
|
||||
waveformDurationSec: nextDurationSec,
|
||||
};
|
||||
});
|
||||
}),
|
||||
listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => {
|
||||
const current = usePlayerStore.getState().currentTrack;
|
||||
if (!current || !payload) return;
|
||||
@@ -1167,12 +1168,10 @@ export function initAudioListeners(): () => void {
|
||||
if (!payload?.trackId) return;
|
||||
const payloadTrackId = normalizeAnalysisTrackId(payload.trackId);
|
||||
if (!payloadTrackId) return;
|
||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||
const currentRaw = usePlayerStore.getState().currentTrack?.id;
|
||||
const currentId = currentRaw ? normalizeAnalysisTrackId(currentRaw) : null;
|
||||
if (currentId && payloadTrackId === currentId) {
|
||||
if (!payload.isPartial) {
|
||||
usePlayerStore.setState({ waveformIsPartial: false });
|
||||
}
|
||||
void refreshWaveformForTrack(currentId);
|
||||
void refreshWaveformForTrack(currentRaw!);
|
||||
void refreshLoudnessForTrack(currentId);
|
||||
emitNormalizationDebug('backfill:applied', { trackId: currentId });
|
||||
return;
|
||||
@@ -1265,7 +1264,12 @@ export function initAudioListeners(): () => void {
|
||||
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) {
|
||||
@@ -1281,6 +1285,7 @@ export function initAudioListeners(): () => void {
|
||||
// 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,
|
||||
@@ -1289,10 +1294,16 @@ export function initAudioListeners(): () => void {
|
||||
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
|
||||
const normChanged =
|
||||
state.normalizationEngine !== prevNormEngine
|
||||
|| state.loudnessTargetLufs !== prevNormTarget;
|
||||
|| 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,
|
||||
@@ -1309,10 +1320,13 @@ export function initAudioListeners(): () => void {
|
||||
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 (currentId) {
|
||||
if (onlyPreAnalysisChanged) {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
} else if (currentId) {
|
||||
void refreshLoudnessForTrack(currentId).finally(() => {
|
||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||
});
|
||||
@@ -1504,9 +1518,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
(set, get) => ({
|
||||
currentTrack: null,
|
||||
waveformBins: null,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
waveformDurationSec: 0,
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
@@ -1643,9 +1654,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentTime: 0,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
waveformDurationSec: 0,
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
@@ -1690,9 +1698,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentRadio: station,
|
||||
currentTrack: null,
|
||||
waveformBins: null,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
waveformDurationSec: 0,
|
||||
normalizationNowDb: null,
|
||||
normalizationTargetLufs: null,
|
||||
normalizationEngineLive: 'off',
|
||||
@@ -1804,9 +1809,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentTrack: track,
|
||||
currentRadio: null,
|
||||
waveformBins: null,
|
||||
waveformIsPartial: false,
|
||||
waveformKnownUntilSec: 0,
|
||||
waveformDurationSec: 0,
|
||||
...deriveNormalizationSnapshot(track, newQueue, idx >= 0 ? idx : 0),
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
@@ -1848,11 +1850,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: track.duration,
|
||||
replayGainDb,
|
||||
replayGainPeak,
|
||||
loudnessGainDb: cachedLoudnessGainByTrackId[track.id] ?? null,
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
analysisTrackId: track.id,
|
||||
})
|
||||
.then(() => {
|
||||
if (playGeneration !== gen) return;
|
||||
@@ -2029,11 +2032,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: trackToPlay.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
loudnessGainDb: cachedLoudnessGainByTrackId[trackToPlay.id] ?? null,
|
||||
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);
|
||||
@@ -2064,11 +2068,12 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
durationHint: currentTrack.duration,
|
||||
replayGainDb: replayGainDbCold,
|
||||
replayGainPeak: replayGainPeakCold,
|
||||
loudnessGainDb: cachedLoudnessGainByTrackId[currentTrack.id] ?? null,
|
||||
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);
|
||||
@@ -2358,6 +2363,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
...state.queue.slice(firstAutoIdx),
|
||||
];
|
||||
syncQueueToServer(newQueue, state.currentTrack, state.currentTime);
|
||||
prefetchLoudnessForEnqueuedTracks(tracks, newQueue, state.queueIndex);
|
||||
return { queue: newQueue };
|
||||
});
|
||||
},
|
||||
@@ -2404,6 +2410,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 };
|
||||
});
|
||||
},
|
||||
@@ -2495,6 +2502,7 @@ 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);
|
||||
|
||||
Reference in New Issue
Block a user