mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +00:00
305 lines
10 KiB
Rust
305 lines
10 KiB
Rust
//! Audio-stage settings commands: volume, replay-gain / loudness normalization,
|
|
//! 10-band EQ, crossfade, gapless.
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use tauri::{AppHandle, State};
|
|
|
|
use super::engine::AudioEngine;
|
|
use super::helpers::*;
|
|
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
|
|
|
#[tauri::command]
|
|
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
|
|
let mut cur = state.current.lock().unwrap();
|
|
cur.base_volume = volume.clamp(0.0, 1.0);
|
|
if let Some(sink) = &cur.sink {
|
|
let prev_effective = sink_volume_now(sink);
|
|
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);
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(clippy::too_many_arguments)]
|
|
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 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`
|
|
// for the uncached path (`effective_loudness_db` / UI gain follow from `compute_gain`).
|
|
let server_for_loudness = crate::helpers::current_playback_server_id_str(&state);
|
|
let cache_loudness = url_for_loudness.as_deref().and_then(|u| {
|
|
resolve_loudness_gain_from_cache_impl(
|
|
&app,
|
|
u,
|
|
target_lufs,
|
|
logical_for_loudness.as_deref(),
|
|
&server_for_loudness,
|
|
ResolveLoudnessCacheOpts {
|
|
touch_waveform: false,
|
|
log_soft_misses: false,
|
|
},
|
|
)
|
|
});
|
|
let effective_loudness_db = if norm_mode == 2 {
|
|
match url_for_loudness.as_deref() {
|
|
Some(_u) => loudness_gain_db_after_resolve(
|
|
cache_loudness,
|
|
target_lufs,
|
|
pre_analysis_db,
|
|
true,
|
|
loudness_gain_db,
|
|
),
|
|
None => {
|
|
loudness_gain_db.or(Some(loudness_gain_placeholder_until_cache(
|
|
target_lufs,
|
|
pre_analysis_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(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
|
|
);
|
|
if state
|
|
.interrupt_outgoing_duck_active
|
|
.load(Ordering::Relaxed)
|
|
{
|
|
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
|
|
// ramp A back to full gain before the handoff swap.
|
|
return;
|
|
}
|
|
let mut cur = state.current.lock().unwrap();
|
|
cur.replay_gain_linear = gain_linear;
|
|
cur.base_volume = volume.clamp(0.0, 1.0);
|
|
if let Some(sink) = &cur.sink {
|
|
let prev_effective = sink_volume_now(sink);
|
|
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
|
|
}
|
|
drop(cur);
|
|
maybe_emit_normalization_state(
|
|
&app,
|
|
NormalizationStatePayload {
|
|
engine: normalization_engine_name(norm_mode).to_string(),
|
|
current_gain_db,
|
|
target_lufs,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) {
|
|
state.eq_enabled.store(enabled, Ordering::Relaxed);
|
|
state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed);
|
|
for (i, &gain) in gains.iter().enumerate() {
|
|
state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
|
|
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
|
|
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
|
state.gapless_enabled.store(enabled, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Duck the current sink over `fade_secs` without exhausting its source (which
|
|
/// would spuriously emit `audio:ended` before the interrupt handoff).
|
|
#[tauri::command]
|
|
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
|
|
let fade_secs = fade_secs.clamp(0.1, 12.0);
|
|
let cur = state.current.lock().unwrap();
|
|
let Some(sink) = cur.sink.as_ref() else {
|
|
return;
|
|
};
|
|
state
|
|
.interrupt_outgoing_duck_active
|
|
.store(true, Ordering::Relaxed);
|
|
cancel_sink_volume_ramp();
|
|
let from = sink_volume_now(sink);
|
|
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
|
|
}
|
|
|
|
/// AutoDJ: when `true`, the progress task stops firing its autonomous
|
|
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
|
|
/// (only when the next track is actually playable). When `false`, the engine's
|
|
/// normal early crossfade trigger is restored (plain crossfade / loud→loud).
|
|
#[tauri::command]
|
|
pub fn audio_set_autodj_suppress(enabled: bool, state: State<'_, AudioEngine>) {
|
|
state
|
|
.autodj_suppress_autocrossfade
|
|
.store(enabled, Ordering::Relaxed);
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn audio_set_playback_rate(
|
|
enabled: bool,
|
|
strategy: String,
|
|
speed: f32,
|
|
pitch_semitones: f32,
|
|
state: State<'_, AudioEngine>,
|
|
) {
|
|
use crate::playback_rate::{
|
|
content_position_from_samples, is_effect_active, raw_counter_samples_for_content_position,
|
|
uses_preserve_dsp, STRATEGY_PRESERVE_PITCH, STRATEGY_SPEED_CORRECTED,
|
|
STRATEGY_VARISPEED,
|
|
};
|
|
|
|
let clamped_speed = speed.clamp(0.5, 2.0);
|
|
let clamped_pitch = pitch_semitones.clamp(-12.0, 12.0);
|
|
let old_enabled = state.playback_rate.enabled.load(Ordering::Relaxed);
|
|
let old_strat = state.playback_rate.load_strategy();
|
|
let old_speed = state.playback_rate.load_speed();
|
|
let was_active = is_effect_active(&state.playback_rate);
|
|
let new_strat = match strategy.as_str() {
|
|
"preserve_pitch" => STRATEGY_PRESERVE_PITCH,
|
|
"speed_corrected" => STRATEGY_SPEED_CORRECTED,
|
|
_ => STRATEGY_VARISPEED,
|
|
};
|
|
let speed_changed = (clamped_speed - old_speed).abs() > 0.001;
|
|
|
|
let restamp_content = if was_active
|
|
&& enabled == old_enabled
|
|
&& uses_preserve_dsp(old_strat)
|
|
&& new_strat == old_strat
|
|
&& speed_changed
|
|
{
|
|
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
|
let channels = state.current_channels.load(Ordering::Relaxed);
|
|
if sample_rate > 0 && channels > 0 {
|
|
Some(content_position_from_samples(
|
|
state.samples_played.load(Ordering::Relaxed),
|
|
sample_rate,
|
|
channels,
|
|
&state.playback_rate,
|
|
))
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
state
|
|
.playback_rate
|
|
.enabled
|
|
.store(enabled, Ordering::Relaxed);
|
|
state
|
|
.playback_rate
|
|
.strategy
|
|
.store(new_strat, Ordering::Relaxed);
|
|
state
|
|
.playback_rate
|
|
.speed
|
|
.store(clamped_speed.to_bits(), Ordering::Relaxed);
|
|
state
|
|
.playback_rate
|
|
.pitch_semitones
|
|
.store(clamped_pitch.to_bits(), Ordering::Relaxed);
|
|
|
|
if let Some(content_secs) = restamp_content {
|
|
if is_effect_active(&state.playback_rate) {
|
|
let sample_rate = state.current_sample_rate.load(Ordering::Relaxed);
|
|
let channels = state.current_channels.load(Ordering::Relaxed);
|
|
state.samples_played.store(
|
|
raw_counter_samples_for_content_position(
|
|
content_secs,
|
|
sample_rate,
|
|
channels,
|
|
&state.playback_rate,
|
|
),
|
|
Ordering::Relaxed,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
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,
|
|
_ => 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);
|
|
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} pre_analysis_db={:.2}",
|
|
engine,
|
|
normalization_engine_name(mode),
|
|
target,
|
|
pre
|
|
);
|
|
maybe_emit_normalization_state(
|
|
&app,
|
|
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,
|
|
},
|
|
);
|
|
}
|