mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(audio): stabilize loudness normalization and streaming startup behavior
Improve normalization consistency by separating cache refresh for non-playing tracks, hardening track-id cache lookups, and keeping UI gain state aligned with actual playback. Also reduce startup stalls by preferring non-seekable streaming when format hints are missing and by tuning ALSA/analysis paths to avoid underrun-prone churn.
This commit is contained in:
+613
-16
@@ -21,7 +21,41 @@ use symphonia::core::{
|
||||
units::{self, Time},
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PartialWaveformPayload {
|
||||
track_id: Option<String>,
|
||||
bins: Vec<u8>,
|
||||
known_until_sec: f64,
|
||||
duration_sec: f64,
|
||||
is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PartialLoudnessPayload {
|
||||
track_id: Option<String>,
|
||||
gain_db: f32,
|
||||
target_lufs: f32,
|
||||
is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WaveformUpdatedPayload {
|
||||
track_id: String,
|
||||
is_partial: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NormalizationStatePayload {
|
||||
engine: String,
|
||||
current_gain_db: Option<f32>,
|
||||
target_lufs: f32,
|
||||
}
|
||||
|
||||
|
||||
// ─── 10-Band Graphic Equalizer ────────────────────────────────────────────────
|
||||
|
||||
@@ -1021,17 +1055,20 @@ async fn track_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
app: AppHandle,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
mut prod: HeapProducer<u8>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
) {
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut reconnects: u32 = 0;
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let mut capture: Vec<u8> = Vec::new();
|
||||
let mut capture_over_limit = false;
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
'outer: loop {
|
||||
let response = if let Some(r) = next_response.take() {
|
||||
r
|
||||
@@ -1120,12 +1157,29 @@ async fn track_download_task(
|
||||
capture_over_limit = true;
|
||||
}
|
||||
}
|
||||
if !capture_over_limit
|
||||
&& last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS)
|
||||
{
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
emit_partial_loudness_from_bytes(&app, &url, &capture, target_lufs);
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
}
|
||||
offset += pushed;
|
||||
downloaded += pushed as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !capture_over_limit && !capture.is_empty() {
|
||||
if let Some(track_id) = playback_identity(&url) {
|
||||
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &capture) {
|
||||
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload { track_id, is_partial: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: capture,
|
||||
@@ -1144,12 +1198,15 @@ async fn ranged_download_task(
|
||||
gen: u64,
|
||||
gen_arc: Arc<AtomicU64>,
|
||||
http_client: reqwest::Client,
|
||||
app: AppHandle,
|
||||
duration_hint: f64,
|
||||
url: String,
|
||||
initial_response: reqwest::Response,
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
downloaded_to: Arc<AtomicUsize>,
|
||||
done: Arc<AtomicBool>,
|
||||
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||
normalization_target_lufs: Arc<AtomicU32>,
|
||||
) {
|
||||
let total_size = buf.lock().unwrap().len();
|
||||
let mut downloaded: usize = 0;
|
||||
@@ -1157,6 +1214,8 @@ async fn ranged_download_task(
|
||||
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||
let dl_started = Instant::now();
|
||||
let mut next_progress_mb: usize = 1;
|
||||
let mut last_partial_emit = Instant::now();
|
||||
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||
|
||||
'outer: loop {
|
||||
let response = if let Some(r) = next_response.take() {
|
||||
@@ -1231,6 +1290,51 @@ async fn ranged_download_task(
|
||||
}
|
||||
downloaded += n;
|
||||
downloaded_to.store(downloaded, Ordering::SeqCst);
|
||||
if downloaded >= 4096
|
||||
&& total_size > 0
|
||||
&& last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded)
|
||||
{
|
||||
let bins =
|
||||
derive_partial_waveform_bins_short_locks(&buf, downloaded, 500);
|
||||
if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) {
|
||||
let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed));
|
||||
if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) {
|
||||
let _ = app.emit(
|
||||
"analysis:loudness-partial",
|
||||
PartialLoudnessPayload {
|
||||
track_id: playback_identity(&url),
|
||||
gain_db: provisional_db,
|
||||
target_lufs,
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness provisional progress={:.2}% gain_db={:.2} target_lufs={:.2} track_id={:?}",
|
||||
(downloaded as f32 / total_size as f32) * 100.0,
|
||||
provisional_db,
|
||||
target_lufs,
|
||||
playback_identity(&url)
|
||||
);
|
||||
}
|
||||
last_partial_loudness_emit = Instant::now();
|
||||
};
|
||||
let known_until_sec = if duration_hint > 0.0 {
|
||||
(duration_hint * downloaded as f64 / total_size as f64).clamp(0.0, duration_hint)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-partial",
|
||||
PartialWaveformPayload {
|
||||
track_id: playback_identity(&url),
|
||||
bins,
|
||||
known_until_sec,
|
||||
duration_sec: duration_hint.max(0.0),
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
last_partial_emit = Instant::now();
|
||||
}
|
||||
let mb = downloaded / (1024 * 1024);
|
||||
if mb >= next_progress_mb {
|
||||
let pct = (downloaded as f64 / total_size as f64 * 100.0) as u32;
|
||||
@@ -1262,11 +1366,144 @@ async fn ranged_download_task(
|
||||
|
||||
if downloaded == total_size && total_size > 0 && total_size <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||
let data = buf.lock().unwrap().clone();
|
||||
if let Some(track_id) = playback_identity(&url) {
|
||||
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
|
||||
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload { track_id, is_partial: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
crate::app_deprintln!("[stream] promoted to stream_completed_cache for replay");
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall-clock spacing for `analysis:waveform-partial` — larger buffers cost more
|
||||
/// to summarize, so we slow emits and keep UI responsive without CPU spikes.
|
||||
fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration {
|
||||
const MB: usize = 1024 * 1024;
|
||||
if downloaded <= 3 * MB {
|
||||
Duration::from_millis(280)
|
||||
} else if downloaded <= 10 * MB {
|
||||
Duration::from_millis(650)
|
||||
} else {
|
||||
Duration::from_millis(1100)
|
||||
}
|
||||
}
|
||||
|
||||
/// Max centered-byte samples examined per bin for partial waveforms (full track
|
||||
/// analysis still uses dense scans elsewhere). Keeps work O(bin_count × cap).
|
||||
const PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP: usize = 2048;
|
||||
|
||||
fn peak_centered_byte_sampled(region: &[u8]) -> u8 {
|
||||
if region.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let cap = PARTIAL_WAVEFORM_SAMPLES_PER_BIN_CAP;
|
||||
let mut peak: u8 = 0;
|
||||
if region.len() <= cap {
|
||||
for &b in region {
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let step = (region.len() / cap).max(1);
|
||||
let mut i = 0;
|
||||
while i < region.len() {
|
||||
let b = region[i];
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
i = i.saturating_add(step);
|
||||
}
|
||||
let b = region[region.len() - 1];
|
||||
let centered = if b >= 128 { b - 128 } else { 128 - b };
|
||||
if centered > peak {
|
||||
peak = centered;
|
||||
}
|
||||
}
|
||||
peak
|
||||
}
|
||||
|
||||
/// Partial waveform without cloning the whole download buffer and without
|
||||
/// holding `buf` locked across all bins (that would stall the decoder's `read()`).
|
||||
fn derive_partial_waveform_bins_short_locks(
|
||||
buf: &Arc<Mutex<Vec<u8>>>,
|
||||
downloaded: usize,
|
||||
bin_count: usize,
|
||||
) -> Vec<u8> {
|
||||
if downloaded == 0 || bin_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let len = downloaded;
|
||||
let mut out = vec![0u8; bin_count];
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
let start = i * len / bin_count;
|
||||
let end = ((i + 1) * len / bin_count).max(start + 1).min(len);
|
||||
let peak = {
|
||||
let b = buf.lock().unwrap();
|
||||
if start >= b.len() {
|
||||
0u8
|
||||
} else {
|
||||
let end = end.min(b.len());
|
||||
peak_centered_byte_sampled(&b[start..end])
|
||||
}
|
||||
};
|
||||
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_partial_loudness_from_bytes(app: &AppHandle, url: &str, bytes: &[u8], target_lufs: f32) {
|
||||
if bytes.len() < PARTIAL_LOUDNESS_MIN_BYTES {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness skip reason=insufficient-bytes bytes={} min_bytes={}",
|
||||
bytes.len(),
|
||||
PARTIAL_LOUDNESS_MIN_BYTES
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Lightweight fallback based on buffered bytes count to keep CPU low.
|
||||
let mb = bytes.len() as f32 / (1024.0 * 1024.0);
|
||||
let floor_db = (target_lufs + 11.0).clamp(-6.0, -1.5);
|
||||
let gain_db = (-(mb * 0.7)).max(floor_db).min(0.0);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] partial-loudness emit bytes={} gain_db={:.2} target_lufs={:.2} track_id={:?}",
|
||||
bytes.len(),
|
||||
gain_db,
|
||||
target_lufs,
|
||||
playback_identity(url)
|
||||
);
|
||||
let _ = app.emit(
|
||||
"analysis:loudness-partial",
|
||||
PartialLoudnessPayload {
|
||||
track_id: playback_identity(url),
|
||||
gain_db: gain_db as f32,
|
||||
target_lufs,
|
||||
is_partial: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn provisional_loudness_gain_from_progress(downloaded: usize, total_size: usize, target_lufs: f32) -> Option<f32> {
|
||||
if total_size == 0 || downloaded == 0 {
|
||||
return None;
|
||||
}
|
||||
let progress = (downloaded as f32 / total_size as f32).clamp(0.0, 1.0);
|
||||
// Move from startup attenuation toward a more realistic late-stream level.
|
||||
// This avoids staying near -2 dB and then jumping hard when final LUFS lands.
|
||||
let start_db = LOUDNESS_STARTUP_ATTENUATION_DB.min(0.0);
|
||||
let end_db = (target_lufs + 6.0).clamp(-10.0, -3.0).min(0.0);
|
||||
let shaped = progress.powf(0.75);
|
||||
Some(start_db + (end_db - start_db) * shaped)
|
||||
}
|
||||
|
||||
fn content_type_to_hint(ct: &str) -> Option<String> {
|
||||
let ct = ct.to_ascii_lowercase();
|
||||
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
|
||||
@@ -1999,6 +2236,10 @@ pub struct AudioEngine {
|
||||
/// When true, audio_play chains sources to the existing Sink instead of
|
||||
/// creating a new one, achieving sample-accurate gapless transitions.
|
||||
pub gapless_enabled: Arc<AtomicBool>,
|
||||
/// 0=off, 1=replaygain, 2=loudness (future runtime loudness engine).
|
||||
pub normalization_engine: Arc<AtomicU32>,
|
||||
/// Target loudness in LUFS for loudness engine (future use).
|
||||
pub normalization_target_lufs: Arc<AtomicU32>,
|
||||
/// Info about the next-up chained track (gapless mode).
|
||||
/// The progress task reads this when `current_source_done` fires.
|
||||
pub(crate) chained_info: Arc<Mutex<Option<ChainedInfo>>>,
|
||||
@@ -2015,6 +2256,10 @@ pub struct AudioEngine {
|
||||
/// Active radio session state. None for regular (non-radio) tracks.
|
||||
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
|
||||
pub(crate) radio_state: Mutex<Option<RadioLiveState>>,
|
||||
/// URL last committed to `AudioCurrent` — used so `audio_update_replay_gain` can
|
||||
/// resolve LUFS / startup trim when the frontend passes `loudnessGainDb: null`
|
||||
/// (otherwise `compute_gain` would treat that as unity gain and playback "jumps").
|
||||
pub(crate) current_playback_url: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
pub struct AudioCurrent {
|
||||
@@ -2190,11 +2435,13 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
// Set PipeWire / PulseAudio latency hints before the first open.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Match cpal ALSA ~200 ms headroom: larger quantum reduces underruns when
|
||||
// the decoder thread catches up after seek or competes with other work.
|
||||
if std::env::var("PIPEWIRE_LATENCY").is_err() {
|
||||
std::env::set_var("PIPEWIRE_LATENCY", "4096/48000");
|
||||
std::env::set_var("PIPEWIRE_LATENCY", "8192/48000");
|
||||
}
|
||||
if std::env::var("PULSE_LATENCY_MSEC").is_err() {
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", "85");
|
||||
std::env::set_var("PULSE_LATENCY_MSEC", "170");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2276,12 +2523,15 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||
gapless_enabled: Arc::new(AtomicBool::new(false)),
|
||||
normalization_engine: Arc::new(AtomicU32::new(0)),
|
||||
normalization_target_lufs: Arc::new(AtomicU32::new((-16.0f32).to_bits())),
|
||||
chained_info: Arc::new(Mutex::new(None)),
|
||||
samples_played: Arc::new(AtomicU64::new(0)),
|
||||
current_sample_rate: Arc::new(AtomicU32::new(0)),
|
||||
current_channels: Arc::new(AtomicU32::new(2)),
|
||||
gapless_switch_at: Arc::new(AtomicU64::new(0)),
|
||||
radio_state: Mutex::new(None),
|
||||
current_playback_url: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
(engine, thread)
|
||||
@@ -2343,6 +2593,101 @@ fn same_playback_target(a_url: &str, b_url: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_loudness_gain_from_cache(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
target_lufs: f32,
|
||||
requested_loudness_gain_db: Option<f32>,
|
||||
) -> Option<f32> {
|
||||
// Never trust `requested` alone: the frontend may pass the *next* track's gain
|
||||
// while `current_playback_url` still lags one play behind. Always prefer a
|
||||
// cache row for **this** URL's track_id; use `requested` only on cache miss
|
||||
// (provisional / pre-seed from JS).
|
||||
let Some(track_id) = playback_identity(url) else {
|
||||
if let Some(r) = requested_loudness_gain_db {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=request-no-identity arg={:.4}",
|
||||
r
|
||||
);
|
||||
}
|
||||
return requested_loudness_gain_db;
|
||||
};
|
||||
let Some(cache) = app.try_state::<crate::analysis_cache::AnalysisCache>() else {
|
||||
if let Some(r) = requested_loudness_gain_db {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=request-no-cache arg={:.4} track_id={}",
|
||||
r,
|
||||
track_id
|
||||
);
|
||||
}
|
||||
return requested_loudness_gain_db;
|
||||
};
|
||||
// Also touch waveform row here so playback path verifies current context is present.
|
||||
let _ = cache.get_latest_waveform_for_track(&track_id);
|
||||
match cache.get_latest_loudness_for_track(&track_id) {
|
||||
Ok(Some(row)) if row.integrated_lufs.is_finite() => {
|
||||
let recommended = crate::analysis_cache::recommended_gain_for_target(
|
||||
row.integrated_lufs,
|
||||
row.true_peak,
|
||||
target_lufs as f64,
|
||||
) as f32;
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache track_id={} gain_db={:.2} target_lufs={:.2} integrated_lufs={:.2} updated_at={}",
|
||||
track_id,
|
||||
recommended,
|
||||
target_lufs,
|
||||
row.integrated_lufs,
|
||||
row.updated_at
|
||||
);
|
||||
Some(recommended)
|
||||
}
|
||||
Ok(Some(row)) => {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache-invalid track_id={} integrated_lufs={}",
|
||||
track_id,
|
||||
row.integrated_lufs
|
||||
);
|
||||
None
|
||||
}
|
||||
Ok(None) => {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache-miss track_id={}",
|
||||
track_id
|
||||
);
|
||||
if let Some(r) = requested_loudness_gain_db {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=request-fallback track_id={} arg={:.4}",
|
||||
track_id,
|
||||
r
|
||||
);
|
||||
Some(r)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_deprintln!(
|
||||
"[normalization] resolve_loudness_gain source=cache-error track_id={} err={}",
|
||||
track_id,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LUFS mode: use cache / explicit `requested`, else a **conservative** trim until
|
||||
/// analysis exists — must never return `None` here or `compute_gain` uses unity.
|
||||
fn loudness_gain_db_or_startup(
|
||||
app: &AppHandle,
|
||||
url: &str,
|
||||
target_lufs: f32,
|
||||
requested: Option<f32>,
|
||||
) -> Option<f32> {
|
||||
resolve_loudness_gain_from_cache(app, url, target_lufs, requested)
|
||||
.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB))
|
||||
}
|
||||
|
||||
/// Take (consume) completed manual-stream bytes if they correspond to `url`.
|
||||
pub(crate) fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option<Vec<u8>> {
|
||||
let mut guard = state.stream_completed_cache.lock().unwrap();
|
||||
@@ -2439,23 +2784,104 @@ async fn fetch_data(
|
||||
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
|
||||
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
|
||||
const MASTER_HEADROOM: f32 = 0.891_254;
|
||||
const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024;
|
||||
const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 350;
|
||||
/// Until integrated LUFS is known, stay clearly below "full" level so a follow-up
|
||||
/// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor.
|
||||
const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0;
|
||||
|
||||
fn compute_gain(
|
||||
normalization_engine: u32,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
volume: f32,
|
||||
) -> (f32, f32) {
|
||||
let gain_linear = replay_gain_db
|
||||
.map(|db| 10f32.powf((db + pre_gain_db) / 20.0))
|
||||
.unwrap_or_else(|| 10f32.powf(fallback_db / 20.0));
|
||||
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
|
||||
let gain_linear = match normalization_engine {
|
||||
2 => loudness_gain_db
|
||||
.map(|db| 10f32.powf(db / 20.0))
|
||||
.unwrap_or(1.0),
|
||||
1 => replay_gain_db
|
||||
.map(|db| 10f32.powf((db + pre_gain_db) / 20.0))
|
||||
.unwrap_or_else(|| 10f32.powf(fallback_db / 20.0)),
|
||||
_ => 1.0,
|
||||
};
|
||||
let peak = if normalization_engine == 1 {
|
||||
replay_gain_peak.unwrap_or(1.0).max(0.001)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let gain_linear = gain_linear.min(1.0 / peak);
|
||||
let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
(gain_linear, effective)
|
||||
}
|
||||
|
||||
fn normalization_engine_name(mode: u32) -> &'static str {
|
||||
match mode {
|
||||
1 => "replaygain",
|
||||
2 => "loudness",
|
||||
_ => "off",
|
||||
}
|
||||
}
|
||||
|
||||
fn gain_linear_to_db(gain_linear: f32) -> Option<f32> {
|
||||
if gain_linear.is_finite() && gain_linear > 0.0 {
|
||||
Some(20.0 * gain_linear.log10())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `audio:normalization-state` “Now dB” for the UI: omit a number while loudness
|
||||
/// mode is still on the **startup safety trim** only (no cache row / no explicit
|
||||
/// requested gain from analysis), so users do not read `-6 dB` as measured LUFS.
|
||||
fn loudness_ui_current_gain_db(
|
||||
norm_mode: u32,
|
||||
resolved_loudness_gain_db: Option<f32>,
|
||||
gain_linear: f32,
|
||||
) -> Option<f32> {
|
||||
if norm_mode == 2 && resolved_loudness_gain_db.is_none() {
|
||||
None
|
||||
} else {
|
||||
gain_linear_to_db(gain_linear)
|
||||
}
|
||||
}
|
||||
|
||||
fn ramp_sink_volume(sink: Arc<Sink>, from: f32, to: f32) {
|
||||
let from = from.clamp(0.0, 1.0);
|
||||
let to = to.clamp(0.0, 1.0);
|
||||
if (to - from).abs() < 0.002 {
|
||||
sink.set_volume(to);
|
||||
return;
|
||||
}
|
||||
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
std::thread::spawn(move || {
|
||||
let delta = (to - from).abs();
|
||||
// Stretch large corrections to avoid audible "step down" moments.
|
||||
let (steps, step_ms): (usize, u64) = if delta > 0.30 {
|
||||
(24, 35)
|
||||
} else if delta > 0.18 {
|
||||
(18, 30)
|
||||
} else if delta > 0.10 {
|
||||
(14, 24)
|
||||
} else {
|
||||
(8, 16)
|
||||
};
|
||||
for i in 1..=steps {
|
||||
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
|
||||
return;
|
||||
}
|
||||
let t = i as f32 / steps as f32;
|
||||
let v = from + (to - from) * t;
|
||||
sink.set_volume(v.clamp(0.0, 1.0));
|
||||
std::thread::sleep(Duration::from_millis(step_ms));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2465,6 +2891,7 @@ pub async fn audio_play(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
@@ -2541,6 +2968,11 @@ pub async fn audio_play(
|
||||
old.stop();
|
||||
}
|
||||
|
||||
// Pin the logical playback URL immediately so `audio_update_replay_gain` (e.g. from
|
||||
// a fast `refreshLoudness` after `playTrack`) resolves LUFS for **this** track, not
|
||||
// the previous URL still stored until the sink swap completes.
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
|
||||
// Extract format hint from URL for better symphonia probing. Strip the
|
||||
// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||
// don't latch onto random query-param substrings; only accept short
|
||||
@@ -2646,7 +3078,11 @@ pub async fn audio_play(
|
||||
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||
let total_size = response.content_length();
|
||||
|
||||
if let (true, Some(total)) = (supports_range, total_size) {
|
||||
// Guardrail: when format/container hint is unknown, some demuxers may
|
||||
// seek near EOF during probe. With a progressively downloaded ranged
|
||||
// source that can delay first audible samples until most/all bytes are
|
||||
// fetched. Prefer sequential streaming in that case for faster start.
|
||||
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||
let total_usize = total as usize;
|
||||
crate::app_deprintln!(
|
||||
"[stream] RangedHttpSource selected — total={} KB, hint={:?}",
|
||||
@@ -2660,12 +3096,15 @@ pub async fn audio_play(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(&state),
|
||||
app.clone(),
|
||||
duration_hint,
|
||||
url.clone(),
|
||||
response,
|
||||
buf.clone(),
|
||||
downloaded_to.clone(),
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
));
|
||||
let reader = RangedHttpSource {
|
||||
buf,
|
||||
@@ -2683,8 +3122,8 @@ pub async fn audio_play(
|
||||
}
|
||||
} else {
|
||||
crate::app_deprintln!(
|
||||
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}",
|
||||
supports_range, total_size
|
||||
"[stream] legacy AudioStreamReader (non-seekable) — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||
supports_range, total_size, stream_hint
|
||||
);
|
||||
let buffer_cap = total_size
|
||||
.map(|n| n as usize)
|
||||
@@ -2697,11 +3136,13 @@ pub async fn audio_play(
|
||||
gen,
|
||||
state.generation.clone(),
|
||||
audio_http_client(&state),
|
||||
app.clone(),
|
||||
url.clone(),
|
||||
response,
|
||||
prod,
|
||||
done.clone(),
|
||||
state.stream_completed_cache.clone(),
|
||||
state.normalization_target_lufs.clone(),
|
||||
));
|
||||
|
||||
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
||||
@@ -2735,7 +3176,45 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let resolved_loudness_gain_db = resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db);
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let startup_loudness_gain_db = if norm_mode == 2 {
|
||||
loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db)
|
||||
} else {
|
||||
resolved_loudness_gain_db
|
||||
};
|
||||
let (gain_linear, effective_volume) = compute_gain(
|
||||
norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
startup_loudness_gain_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_play track_id={:?} engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective_volume={:.3}",
|
||||
playback_identity(&url),
|
||||
normalization_engine_name(norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
resolved_loudness_gain_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
volume,
|
||||
effective_volume
|
||||
);
|
||||
let _ = app.emit(
|
||||
"audio:normalization-state",
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
},
|
||||
);
|
||||
|
||||
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
|
||||
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
|
||||
@@ -2999,6 +3478,7 @@ pub async fn audio_play(
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -3020,9 +3500,11 @@ pub async fn audio_chain_preload(
|
||||
duration_hint: f64,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) -> Result<(), String> {
|
||||
// Idempotent: already chained this track → nothing to do.
|
||||
@@ -3087,7 +3569,22 @@ pub async fn audio_chain_preload(
|
||||
// current track ends, and `Sink::set_volume` affects the WHOLE Sink (incl.
|
||||
// the still-playing current source). Volume for the chained track is
|
||||
// applied at the gapless transition in `spawn_progress_task`, not here.
|
||||
let (gain_linear, _effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let chain_loudness_db = if norm_mode == 2 {
|
||||
loudness_gain_db_or_startup(&app, &url, target_lufs, loudness_gain_db)
|
||||
} else {
|
||||
resolve_loudness_gain_from_cache(&app, &url, target_lufs, loudness_gain_db)
|
||||
};
|
||||
let (gain_linear, _effective_volume) = compute_gain(
|
||||
norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
chain_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
|
||||
let done_next = Arc::new(AtomicBool::new(false));
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
@@ -3189,6 +3686,7 @@ fn spawn_progress_task(
|
||||
sample_rate_arc: Arc<AtomicU32>,
|
||||
channels_arc: Arc<AtomicU32>,
|
||||
gapless_switch_at: Arc<AtomicU64>,
|
||||
current_playback_url: Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut near_end_ticks: u32 = 0;
|
||||
@@ -3239,6 +3737,7 @@ fn spawn_progress_task(
|
||||
// must only be called at the boundary, not at preload.
|
||||
{
|
||||
let mut cur = current_arc.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = info.replay_gain_linear;
|
||||
cur.base_volume = info.base_volume;
|
||||
cur.duration_secs = info.duration_secs;
|
||||
@@ -3246,10 +3745,12 @@ fn spawn_progress_task(
|
||||
cur.play_started = Some(Instant::now());
|
||||
if let Some(sink) = &cur.sink {
|
||||
let effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
}
|
||||
|
||||
*current_playback_url.lock().unwrap() = Some(info.url.clone());
|
||||
|
||||
// Record the gapless switch timestamp for ghost-command guard.
|
||||
let switch_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -3411,6 +3912,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
||||
#[tauri::command]
|
||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||
*state.current_playback_url.lock().unwrap() = None;
|
||||
*state.chained_info.lock().unwrap() = None;
|
||||
*state.stream_completed_cache.lock().unwrap() = None;
|
||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||
@@ -3525,9 +4027,11 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
|
||||
#[tauri::command]
|
||||
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.set_volume((cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0));
|
||||
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3536,17 +4040,67 @@ pub fn audio_update_replay_gain(
|
||||
volume: f32,
|
||||
replay_gain_db: Option<f32>,
|
||||
replay_gain_peak: Option<f32>,
|
||||
loudness_gain_db: Option<f32>,
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
app: AppHandle,
|
||||
state: State<'_, AudioEngine>,
|
||||
) {
|
||||
let (gain_linear, effective) = compute_gain(replay_gain_db, replay_gain_peak, pre_gain_db, fallback_db, volume);
|
||||
let norm_mode = state.normalization_engine.load(Ordering::Relaxed);
|
||||
let target_lufs = f32::from_bits(state.normalization_target_lufs.load(Ordering::Relaxed));
|
||||
let url_for_loudness = if norm_mode == 2 {
|
||||
state.current_playback_url.lock().unwrap().clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resolved_loudness_gain_db = url_for_loudness
|
||||
.as_deref()
|
||||
.and_then(|u| resolve_loudness_gain_from_cache(&app, u, target_lufs, loudness_gain_db));
|
||||
let effective_loudness_db = if norm_mode == 2 {
|
||||
match url_for_loudness.as_deref() {
|
||||
Some(u) => loudness_gain_db_or_startup(&app, u, target_lufs, loudness_gain_db),
|
||||
None => loudness_gain_db.or(Some(LOUDNESS_STARTUP_ATTENUATION_DB)),
|
||||
}
|
||||
} else {
|
||||
loudness_gain_db
|
||||
};
|
||||
let (gain_linear, effective) = compute_gain(
|
||||
norm_mode,
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
effective_loudness_db,
|
||||
pre_gain_db,
|
||||
fallback_db,
|
||||
volume,
|
||||
);
|
||||
let current_gain_db = loudness_ui_current_gain_db(norm_mode, resolved_loudness_gain_db, gain_linear);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_update_replay_gain engine={} replay_gain_db={:?} replay_gain_peak={:?} loudness_gain_db={:?} gain_linear={:.4} current_gain_db={:?} target_lufs={:.2} volume={:.3} effective={:.3}",
|
||||
normalization_engine_name(norm_mode),
|
||||
replay_gain_db,
|
||||
replay_gain_peak,
|
||||
loudness_gain_db,
|
||||
gain_linear,
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
volume,
|
||||
effective
|
||||
);
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
|
||||
cur.replay_gain_linear = gain_linear;
|
||||
cur.base_volume = volume.clamp(0.0, 1.0);
|
||||
if let Some(sink) = &cur.sink {
|
||||
sink.set_volume(effective);
|
||||
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
||||
}
|
||||
let _ = app.emit(
|
||||
"audio:normalization-state",
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(norm_mode).to_string(),
|
||||
current_gain_db,
|
||||
target_lufs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions.
|
||||
@@ -3636,6 +4190,16 @@ pub async fn audio_preload(
|
||||
response.bytes().await.map_err(|e| e.to_string())?.into()
|
||||
};
|
||||
let _ = duration_hint; // kept in API for compatibility
|
||||
if let Some(track_id) = playback_identity(&url) {
|
||||
if let Err(e) = crate::analysis_cache::seed_from_bytes(&app, &track_id, &data) {
|
||||
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
|
||||
} else {
|
||||
let _ = app.emit(
|
||||
"analysis:waveform-updated",
|
||||
WaveformUpdatedPayload { track_id, is_partial: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
let url_for_emit = url.clone();
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
|
||||
let _ = app.emit("audio:preload-ready", url_for_emit);
|
||||
@@ -3780,6 +4344,8 @@ pub async fn audio_play_radio(
|
||||
cur.fadeout_samples = Some(fadeout_samples);
|
||||
}
|
||||
|
||||
*state.current_playback_url.lock().unwrap() = Some(url.clone());
|
||||
|
||||
state.current_sample_rate.store(sample_rate, Ordering::Relaxed);
|
||||
state.current_channels.store(channels as u32, Ordering::Relaxed);
|
||||
|
||||
@@ -3798,6 +4364,7 @@ pub async fn audio_play_radio(
|
||||
state.current_sample_rate.clone(),
|
||||
state.current_channels.clone(),
|
||||
state.gapless_switch_at.clone(),
|
||||
state.current_playback_url.clone(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -3980,6 +4547,36 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
||||
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn audio_set_normalization(engine: String, target_lufs: f32, app: AppHandle, state: State<'_, AudioEngine>) {
|
||||
let mode = match engine.as_str() {
|
||||
"replaygain" => 1,
|
||||
"loudness" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
state.normalization_engine.store(mode, Ordering::Relaxed);
|
||||
let target = target_lufs.clamp(-30.0, -8.0);
|
||||
state
|
||||
.normalization_target_lufs
|
||||
.store(target.to_bits(), Ordering::Relaxed);
|
||||
crate::app_deprintln!(
|
||||
"[normalization] audio_set_normalization requested_engine={} resolved_engine={} target_lufs={:.2}",
|
||||
engine,
|
||||
normalization_engine_name(mode),
|
||||
target
|
||||
);
|
||||
let _ = app.emit(
|
||||
"audio:normalization-state",
|
||||
NormalizationStatePayload {
|
||||
engine: normalization_engine_name(mode).to_string(),
|
||||
// At mode-switch time the effective track gain may not be recalculated yet.
|
||||
// Emit `None` and let audio_play/audio_update_replay_gain publish actual value.
|
||||
current_gain_db: None,
|
||||
target_lufs: target,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Device-change watcher ────────────────────────────────────────────────────
|
||||
//
|
||||
// Polls every 3 s for two conditions:
|
||||
|
||||
Reference in New Issue
Block a user