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:
Maxim Isaev
2026-04-25 22:09:38 +03:00
parent 93b724fc65
commit 86704473ed
14 changed files with 2092 additions and 37 deletions
+613 -16
View File
@@ -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: