Files
Psychotoxical-psysonic/src-tauri/src/audio/helpers.rs
T
Maxim Isaev c917ee1071 refactor(audio): split monolithic audio module into submodules
Move audio engine functionality from a single large file into focused Rust modules while preserving behavior and command surface. This keeps boundaries clearer for future extraction and the upcoming lib.rs split.
2026-05-02 13:44:25 +03:00

564 lines
20 KiB
Rust

//! URL identity, loudness cache resolution, fetch, gain math, and stream analysis helpers.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use rodio::Sink;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
use crate::audio::engine::AudioEngine;
use crate::audio::ipc::{
partial_loudness_should_emit, PartialLoudnessPayload, PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB,
};
pub(crate) 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={}",
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 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);
let track_key = playback_identity(url).unwrap_or_else(|| url.to_string());
if !partial_loudness_should_emit(&track_key, gain_db as f32) {
crate::app_deprintln!(
"[normalization] partial-loudness skip reason=delta-below-threshold gain_db={:.2} threshold_db={:.2} track_id={:?}",
gain_db,
PARTIAL_LOUDNESS_DELTA_THRESHOLD_DB,
playback_identity(url)
);
return;
}
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,
},
);
}
pub(crate) 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 = 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)
}
pub(crate) 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()) }
else if ct.contains("aac") || ct.contains("aacp") { Some("aac".into()) }
else if ct.contains("ogg") { Some("ogg".into()) }
else if ct.contains("flac") { Some("flac".into()) }
else if ct.contains("wav") || ct.contains("wave") { Some("wav".into()) }
else if ct.contains("opus") { Some("opus".into()) }
else { None }
}
// ─── Event payloads ───────────────────────────────────────────────────────────
#[derive(Clone, Serialize)]
pub struct ProgressPayload {
pub current_time: f64,
pub duration: f64,
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/// Subsonic `buildStreamUrl()` uses a fresh random salt on every call, so two
/// URLs for the same track differ in `t`/`s` query params. Compare a stable key.
pub(crate) fn playback_identity(url: &str) -> Option<String> {
if let Some(path) = url.strip_prefix("psysonic-local://") {
return Some(format!("local:{path}"));
}
if !url.contains("stream.view") {
return None;
}
let q = url.split('?').nth(1)?;
for pair in q.split('&') {
if let Some(v) = pair.strip_prefix("id=") {
let v = v.split('&').next().unwrap_or(v);
return Some(format!("stream:{v}"));
}
}
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`.
pub(crate) 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))
}
pub(crate) 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,
_ => a_url == b_url,
}
}
#[derive(Clone, Copy)]
pub(crate) struct ResolveLoudnessCacheOpts {
/// When false, skip `get_latest_waveform_for_track` — `audio_update_replay_gain` runs
/// on every partial-LUFS tick; loudness gain does not depend on waveform, and the extra
/// SQLite read was pure overhead on the IPC path.
pub(crate) touch_waveform: bool,
/// When false, omit `cache-miss` / `cache-invalid` debug lines (still log hits and errors).
pub(crate) log_soft_misses: bool,
}
impl Default for ResolveLoudnessCacheOpts {
fn default() -> Self {
Self {
touch_waveform: true,
log_soft_misses: true,
}
}
}
pub(crate) fn resolve_loudness_gain_from_cache(
app: &AppHandle,
url: &str,
target_lufs: f32,
logical_track_id: Option<&str>,
) -> Option<f32> {
resolve_loudness_gain_from_cache_impl(
app,
url,
target_lufs,
logical_track_id,
ResolveLoudnessCacheOpts::default(),
)
}
pub(crate) fn resolve_loudness_gain_from_cache_impl(
app: &AppHandle,
url: &str,
target_lufs: f32,
logical_track_id: Option<&str>,
opts: ResolveLoudnessCacheOpts,
) -> Option<f32> {
// 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 {
if opts.log_soft_misses {
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 opts.log_soft_misses {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=no-analysis-cache track_id={}",
track_id
);
}
return None;
};
if opts.touch_waveform {
// Bind / preload: verify waveform context exists alongside loudness lookup.
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)) => {
if opts.log_soft_misses {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=cache-invalid track_id={} integrated_lufs={}",
track_id,
row.integrated_lufs
);
}
None
}
Ok(None) => {
if opts.log_soft_misses {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=cache-miss track_id={}",
track_id
);
}
None
}
Err(e) => {
crate::app_deprintln!(
"[normalization] resolve_loudness_gain source=cache-error track_id={} err={}",
track_id,
e
);
None
}
}
}
/// Typical integrated LUFS (streaming pivot) when SQLite has no row yet — so target changes
/// still move gain before real analysis completes.
const LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS: f64 = -14.0;
#[inline]
pub(crate) fn loudness_gain_placeholder_until_cache(target_lufs: f32, pre_analysis_attenuation_db: f32) -> f32 {
let pre = pre_analysis_attenuation_db.clamp(-24.0, 0.0).min(0.0);
// `true_peak = 0.0` skips the headroom cap until integrated measurement exists.
let pivot = crate::analysis_cache::recommended_gain_for_target(
LOUDNESS_PLACEHOLDER_INTEGRATED_LUFS,
0.0,
f64::from(target_lufs),
) as f32;
(pivot + pre).clamp(-24.0, 24.0)
}
/// LUFS gain after a single `resolve_loudness_gain_from_cache` result (`None` = miss).
/// Keeps `audio_update_replay_gain` / `audio_play` from resolving twice on the same URL.
/// Until a cache row exists, follow current target (see [`loudness_gain_placeholder_until_cache`]).
pub(crate) fn loudness_gain_db_after_resolve(
resolved_from_cache: Option<f32>,
target_lufs: f32,
pre_analysis_attenuation_db: f32,
allow_js_when_uncached: bool,
js_gain_db: Option<f32>,
) -> Option<f32> {
let uncached = loudness_gain_placeholder_until_cache(target_lufs, pre_analysis_attenuation_db);
match resolved_from_cache {
Some(g) => Some(g),
None => {
if allow_js_when_uncached {
match js_gain_db {
Some(r) if r.is_finite() => Some(r),
_ => Some(uncached),
}
} else {
Some(uncached)
}
}
}
}
/// 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.
pub(crate) fn loudness_gain_db_or_startup(
app: &AppHandle,
url: &str,
target_lufs: f32,
logical_track_id: Option<&str>,
pre_analysis_attenuation_db: f32,
allow_js_when_uncached: bool,
js_gain_db: Option<f32>,
) -> Option<f32> {
let resolved = resolve_loudness_gain_from_cache_impl(
app,
url,
target_lufs,
logical_track_id,
ResolveLoudnessCacheOpts::default(),
);
loudness_gain_db_after_resolve(
resolved,
target_lufs,
pre_analysis_attenuation_db,
allow_js_when_uncached,
js_gain_db,
)
}
#[inline]
pub(crate) 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`.
pub fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option<Vec<u8>> {
let mut guard = state.stream_completed_cache.lock().unwrap();
if guard
.as_ref()
.is_some_and(|p| same_playback_target(&p.url, url))
{
return guard.take().map(|p| p.data);
}
None
}
/// Fetch track bytes from the preload cache or via HTTP.
pub(crate) async fn fetch_data(
url: &str,
state: &AudioEngine,
gen: u64,
app: &AppHandle,
) -> Result<Option<Vec<u8>>, String> {
// Check completed streamed-track cache first (manual streaming fallback cache).
let streamed_cached = {
let mut streamed = state.stream_completed_cache.lock().unwrap();
if streamed.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
streamed.take().map(|p| p.data)
} else {
None
}
};
if let Some(data) = streamed_cached {
return Ok(Some(data));
}
// Check preload cache next.
let cached = {
let mut preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
preloaded.take().map(|p| p.data)
} else {
None
}
};
if let Some(data) = cached {
return Ok(Some(data));
}
// Offline cache — local file written by download_track_offline.
if let Some(path) = url.strip_prefix("psysonic-local://") {
let data = tokio::fs::read(path).await.map_err(|e| e.to_string())?;
return Ok(Some(data));
}
let response = crate::audio::engine::audio_http_client(&state).get(url).send().await.map_err(|e| e.to_string())?;
let status = response.status();
let ct = response.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let server_hdr = response.headers()
.get("server")
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
// Strip auth params from URL before logging.
let safe_url = url.split('?').next().unwrap_or(url);
crate::app_deprintln!(
"[audio] fetch {} → {} | content-type: {} | server: {}",
safe_url, status, ct, server_hdr
);
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None); // superseded
}
let status = response.status().as_u16();
let msg = format!("HTTP {status}");
app.emit("audio:error", &msg).ok();
return Err(msg);
}
// Stream the body, checking gen between chunks so a rapid manual skip can
// abort a superseded download mid-flight and free bandwidth for the new one.
let hint = response.content_length().unwrap_or(0) as usize;
let mut stream = response.bytes_stream();
let mut data = Vec::with_capacity(hint);
while let Some(chunk) = stream.next().await {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None); // superseded — abort
}
data.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
}
Ok(Some(data))
}
/// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`,
/// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local`
/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform /
/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP.
pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
app: &AppHandle,
cache_track_id: Option<&str>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
bytes: &[u8],
) {
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
if bytes.is_empty() || bytes.len() > crate::audio::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
return;
}
let track_id = track_id.to_string();
let bytes = bytes.to_vec();
let app = app.clone();
let gen_arc = gen_arc.clone();
crate::app_deprintln!(
"[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}",
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::audio::engine::analysis_seed_high_priority_for_track(&app, &track_id);
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if let Err(e) = crate::submit_analysis_cpu_seed(app.clone(), track_id.clone(), bytes, high).await {
crate::app_eprintln!(
"[analysis] in-memory play path seed failed for {}: {}",
track_id,
e
);
}
});
}
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
pub(crate) const MASTER_HEADROOM: f32 = 0.891_254;
pub(crate) const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024;
pub(crate) const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 900;
pub(crate) 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 = 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)
}
pub(crate) fn normalization_engine_name(mode: u32) -> &'static str {
match mode {
1 => "replaygain",
2 => "loudness",
_ => "off",
}
}
pub(crate) 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: effective applied gain, including
/// loudness pre-analysis trim from settings when no cache row exists yet (matches audible level).
pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
gain_linear_to_db(gain_linear)
}
pub(crate) 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));
}
});
}