mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
feat(audio): hi-res transition blend rate for crossfade, AutoDJ, and gapless (#1171)
This commit is contained in:
@@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Added
|
||||
|
||||
### Hi-Res transition blend rate
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1171](https://github.com/Psychotoxical/psysonic/pull/1171)**
|
||||
|
||||
* **Settings → Audio → Native Hi-Res** gains a blend-rate picker (44.1 / 88.2 / 96 kHz, default 44.1 kHz) for transitions when adjacent tracks have different sample rates, with a note that resampling uses extra CPU and memory.
|
||||
* **Crossfade / AutoDJ:** both sides resample to the chosen rate; the output stream reopens when needed and the outgoing track rebuilds from cache so mixed 88.2 ↔ 44.1 kHz transitions no longer tear mid-fade.
|
||||
* **Gapless:** the next track chains at the blend rate and the current track realigns when the stream Hz differs, instead of falling back to a hard cut.
|
||||
|
||||
### Theme store — version numbers and an animated/static filter
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1104](https://github.com/Psychotoxical/psysonic/pull/1104)**
|
||||
|
||||
@@ -13,6 +13,7 @@ use tauri::{AppHandle, Emitter, State};
|
||||
use super::decode::build_source;
|
||||
use super::engine::AudioEngine;
|
||||
use super::helpers::*;
|
||||
use super::hi_res_blend::{self, OutgoingBlendSnapshot};
|
||||
use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
|
||||
use super::play_input::{select_play_input, url_format_hint, PlayInputContext};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs};
|
||||
@@ -51,6 +52,7 @@ pub async fn audio_play(
|
||||
fallback_db: f32,
|
||||
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
|
||||
hi_res_enabled: bool, // false = safe 44.1 kHz mode; true = native rate (alpha)
|
||||
hi_res_crossfade_resample_hz: Option<u32>, // 44100 / 88200 / 96000 when hi-res + crossfade
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
stream_format_suffix: Option<String>,
|
||||
@@ -285,6 +287,13 @@ pub async fn audio_play(
|
||||
0.0
|
||||
};
|
||||
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(
|
||||
hi_res_enabled,
|
||||
crossfade_enabled || (gapless && !manual),
|
||||
hi_res_crossfade_resample_hz,
|
||||
);
|
||||
let resample_target_hz = blend_rate.unwrap_or(0);
|
||||
|
||||
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
// Reset sample counter for the new track.
|
||||
@@ -301,6 +310,7 @@ pub async fn audio_play(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
&state,
|
||||
@@ -339,6 +349,26 @@ pub async fn audio_play(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let outgoing_blend: Option<OutgoingBlendSnapshot> =
|
||||
if let Some(blend) = blend_rate {
|
||||
if crossfade_enabled && current_stream_rate > 0 && current_stream_rate != blend {
|
||||
hi_res_blend::capture_outgoing_blend_snapshot(
|
||||
&state,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if outgoing_blend.is_some() {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
}
|
||||
|
||||
// ── Stream rate management ────────────────────────────────────────────────
|
||||
// Hi-Res ON: open device at file's native rate (bit-perfect, no resampler).
|
||||
// Hi-Res OFF: if the stream was previously opened at a hi-res rate (e.g. the
|
||||
@@ -347,11 +377,12 @@ pub async fn audio_play(
|
||||
// If already at the device default — skip entirely (no IPC, no
|
||||
// PipeWire reconfigure, no scheduler cost).
|
||||
{
|
||||
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
let target_rate = if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
let target_rate = if let Some(blend) = blend_rate {
|
||||
blend
|
||||
} else if hi_res_enabled {
|
||||
output_rate // native file rate
|
||||
} else {
|
||||
state.device_default_rate // restore device default
|
||||
state.device_default_rate // restore device default
|
||||
};
|
||||
let needs_switch = target_rate > 0 && target_rate != current_stream_rate;
|
||||
if needs_switch {
|
||||
@@ -383,6 +414,23 @@ pub async fn audio_play(
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(snap), Some(blend)) = (&outgoing_blend, blend_rate) {
|
||||
if let Err(e) = hi_res_blend::spawn_outgoing_blend_resample(
|
||||
&app,
|
||||
&state,
|
||||
snap,
|
||||
blend,
|
||||
gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(&state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
sink.set_volume(effective_volume);
|
||||
@@ -399,7 +447,7 @@ pub async fn audio_play(
|
||||
// ring buffer time to build headroom before the cpal callback drains it.
|
||||
let needs_preserve_prefill = preserve_pitch_will_run(&state.playback_rate);
|
||||
let needs_prefill =
|
||||
(hi_res_enabled && output_rate > 48_000) || needs_preserve_prefill;
|
||||
(hi_res_enabled && blend_rate.unwrap_or(output_rate) > 48_000) || needs_preserve_prefill;
|
||||
let defer_playback_start = !state.stream_playback_armed.load(Ordering::Relaxed);
|
||||
if needs_prefill || defer_playback_start {
|
||||
sink.pause();
|
||||
@@ -562,6 +610,7 @@ pub async fn audio_chain_preload(
|
||||
pre_gain_db: f32,
|
||||
fallback_db: f32,
|
||||
hi_res_enabled: bool,
|
||||
hi_res_crossfade_resample_hz: Option<u32>,
|
||||
analysis_track_id: Option<String>,
|
||||
server_id: Option<String>,
|
||||
app: AppHandle,
|
||||
@@ -675,8 +724,9 @@ pub async fn audio_chain_preload(
|
||||
// Use a dedicated counter for the chained source — it will be swapped into
|
||||
// samples_played when the chained track becomes active.
|
||||
let chain_counter = Arc::new(AtomicU64::new(0));
|
||||
// Always 0 — no application-level resampling (same as audio_play).
|
||||
let target_rate: u32 = 0;
|
||||
// Always 0 unless hi-res gapless blend resampling is active.
|
||||
let blend_rate = hi_res_blend::blend_rate_hz(hi_res_enabled, hi_res_enabled, hi_res_crossfade_resample_hz);
|
||||
let target_rate: u32 = blend_rate.unwrap_or(0);
|
||||
let format_hint = url.rsplit('.').next()
|
||||
.and_then(|ext| ext.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
@@ -702,23 +752,70 @@ pub async fn audio_chain_preload(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// In hi-res mode: if the next track's native rate differs from the current
|
||||
// output stream, we cannot chain gaplessly — audio_play will do a hard cut
|
||||
// with a stream re-open. Store raw bytes to avoid re-downloading.
|
||||
// In safe mode (44.1 kHz locked): the stream rate is always 44100, so the
|
||||
// chain proceeds and rodio resamples internally — no bail needed.
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
// Hi-res gapless: resample the chained track to the blend rate and realign
|
||||
// the output stream when its Hz differs from the current track.
|
||||
let stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
if let Some(br) = blend_rate {
|
||||
if stream_rate > 0 && stream_rate != br {
|
||||
if let Some(snap) = hi_res_blend::capture_outgoing_blend_snapshot(&state, 0.0, 0.0) {
|
||||
hi_res_blend::detach_current_sink_for_blend_reopen(&state);
|
||||
let dev = state.selected_device.lock().unwrap().clone();
|
||||
if super::engine::open_output_stream_blocking(&state, br, true, dev).is_ok() {
|
||||
if hi_res_enabled && br > 48_000 {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
}
|
||||
if state.generation.load(Ordering::SeqCst) == snapshot_gen {
|
||||
if let Err(e) = hi_res_blend::rebuild_current_track_at_blend_rate(
|
||||
&app,
|
||||
&state,
|
||||
&snap,
|
||||
br,
|
||||
snapshot_gen,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::app_eprintln!("{e}");
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url: url.clone(),
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend stream reopen failed (wanted {br} Hz, had {stream_rate} Hz)"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless blend skipped: current track not cached for realign"
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let next_rate = if hi_res_enabled { built.output_rate } else { 44_100 };
|
||||
if hi_res_enabled && stream_rate > 0 && next_rate != stream_rate {
|
||||
crate::app_eprintln!(
|
||||
"[psysonic] gapless chain skipped: next track rate {} Hz ≠ stream {} Hz",
|
||||
next_rate, stream_rate
|
||||
);
|
||||
*state.preloaded.lock().unwrap() = Some(PreloadedTrack {
|
||||
url,
|
||||
data: Arc::try_unwrap(raw_bytes).unwrap_or_else(|a| (*a).clone()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Append to the existing Sink. The audio hardware stream never stalls.
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(crate) async fn try_resume_after_device_change(
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
resample_target_hz: 0,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
&engine,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Hi-Res transition blend: resample to a user-chosen rate when crossfade,
|
||||
//! AutoDJ, or gapless must cross a sample-rate boundary.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::playback_rate::raw_counter_samples_for_content_position;
|
||||
use super::play_input::{url_format_hint, PlayInput};
|
||||
use super::source_build::{build_playback_source_with_probe_fallback, BuildSourceArgs, PlaybackSource};
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
const BLEND_44100: u32 = 44_100;
|
||||
const BLEND_88200: u32 = 88_200;
|
||||
const BLEND_96000: u32 = 96_000;
|
||||
|
||||
/// User-selected blend rate for hi-res transitions; `None` when inactive.
|
||||
pub(crate) fn blend_rate_hz(
|
||||
hi_res_enabled: bool,
|
||||
transition_blend_active: bool,
|
||||
hz: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
if !hi_res_enabled || !transition_blend_active {
|
||||
return None;
|
||||
}
|
||||
let raw = hz.unwrap_or(BLEND_44100);
|
||||
match raw {
|
||||
BLEND_44100 | BLEND_88200 | BLEND_96000 => Some(raw),
|
||||
_ => Some(BLEND_44100),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct OutgoingBlendSnapshot {
|
||||
pub(crate) url: String,
|
||||
pub(crate) position_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) outgoing_fade_secs: f32,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Capture the currently playing track before a hi-res blend stream reopen.
|
||||
pub(crate) fn capture_outgoing_blend_snapshot(
|
||||
state: &AudioEngine,
|
||||
outgoing_fade_secs: f32,
|
||||
actual_fade_secs: f32,
|
||||
) -> Option<OutgoingBlendSnapshot> {
|
||||
let url = state.current_playback_url.lock().unwrap().clone()?;
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (position_secs, duration_secs, base_volume, gain_linear, playing) = {
|
||||
let cur = state.current.lock().unwrap();
|
||||
let playing = cur.sink.is_some() && cur.paused_at.is_none();
|
||||
(
|
||||
cur.position(),
|
||||
cur.duration_secs,
|
||||
cur.base_volume,
|
||||
cur.replay_gain_linear,
|
||||
playing,
|
||||
)
|
||||
};
|
||||
if !playing {
|
||||
return None;
|
||||
}
|
||||
let analysis_track_id = state.current_analysis_track_id.lock().unwrap().clone();
|
||||
Some(OutgoingBlendSnapshot {
|
||||
url,
|
||||
position_secs,
|
||||
duration_secs,
|
||||
base_volume,
|
||||
gain_linear,
|
||||
outgoing_fade_secs,
|
||||
actual_fade_secs,
|
||||
analysis_track_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the live main sink so a stream reopen does not leave dangling players.
|
||||
pub(crate) fn detach_current_sink_for_blend_reopen(state: &AudioEngine) {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
if let Some(old) = cur.sink.take() {
|
||||
old.stop();
|
||||
}
|
||||
cur.fadeout_trigger = None;
|
||||
cur.fadeout_samples = None;
|
||||
}
|
||||
|
||||
fn resolve_cached_play_input(engine: &AudioEngine, url: &str) -> Option<PlayInput> {
|
||||
if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
return Some(PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[hi-res-blend]",
|
||||
random_access: true,
|
||||
mp4_probe_gate: None,
|
||||
});
|
||||
}
|
||||
|
||||
let ram_bytes = {
|
||||
let guard = engine.stream_completed_cache.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|t| t.url == url)
|
||||
.map(|t| t.data.clone())
|
||||
};
|
||||
let bytes = if let Some(b) = ram_bytes {
|
||||
b
|
||||
} else {
|
||||
let spill_path = {
|
||||
let guard = engine.stream_completed_spill.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.filter(|s| s.url == url)
|
||||
.map(|s| s.path.clone())
|
||||
};
|
||||
match spill_path {
|
||||
Some(p) => std::fs::read(&p).ok()?,
|
||||
None => return None,
|
||||
}
|
||||
};
|
||||
Some(PlayInput::Bytes(bytes))
|
||||
}
|
||||
|
||||
/// Rebuild the outgoing track on `fading_out_sink` at `blend_rate` after reopen.
|
||||
pub(crate) async fn spawn_outgoing_blend_resample(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] outgoing track not cached for blend reopen: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] outgoing seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
let fade_secs = snap.outgoing_fade_secs;
|
||||
if fade_secs > 0.0 {
|
||||
let rate = blend_rate;
|
||||
let ch = state.current_channels.load(Ordering::Relaxed).max(2);
|
||||
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
|
||||
ps.built
|
||||
.fadeout_samples
|
||||
.store(fade_total.max(1), Ordering::SeqCst);
|
||||
ps.built.fadeout_trigger.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
sink.play();
|
||||
*state.fading_out_sink.lock().unwrap() = Some(sink);
|
||||
|
||||
let fo_arc = state.fading_out_sink.clone();
|
||||
let cleanup_secs = snap.actual_fade_secs.max(snap.outgoing_fade_secs) + 0.5;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs_f32(cleanup_secs)).await;
|
||||
if let Some(s) = fo_arc.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
});
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] outgoing rebuilt at {blend_rate} Hz from {:.2}s (fade {:.2}s)",
|
||||
snap.position_secs,
|
||||
fade_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the **current** track on a freshly opened blend-rate stream (gapless
|
||||
/// chain realign) so the next source can append to the same sink.
|
||||
pub(crate) async fn rebuild_current_track_at_blend_rate(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AudioEngine>,
|
||||
snap: &OutgoingBlendSnapshot,
|
||||
blend_rate: u32,
|
||||
gen: u64,
|
||||
) -> Result<(), String> {
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let play_input = resolve_cached_play_input(state, &snap.url).ok_or_else(|| {
|
||||
format!(
|
||||
"[hi-res-blend] current track not cached for gapless realign: {}",
|
||||
snap.url
|
||||
)
|
||||
})?;
|
||||
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
let format_hint = url_format_hint(&snap.url);
|
||||
let stream_format_suffix: Option<String> = snap
|
||||
.url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let resume_server = super::helpers::current_playback_server_id_str(state);
|
||||
|
||||
let ps: PlaybackSource = build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url: &snap.url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
server_id: Some(resume_server.as_str()),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: Duration::from_millis(5),
|
||||
hi_res_enabled: true,
|
||||
resample_target_hz: blend_rate,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
state,
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if state.generation.load(Ordering::SeqCst) != gen {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
state
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let stream = super::engine::ensure_output_stream_open(state)?;
|
||||
let sink = Arc::new(Player::connect_new(stream.mixer()));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
if ps.is_seekable && snap.position_secs > 0.05 {
|
||||
let target = Duration::from_secs_f64(snap.position_secs.max(0.0));
|
||||
sink.try_seek(target)
|
||||
.map_err(|e| format!("[hi-res-blend] gapless realign seek failed: {e}"))?;
|
||||
}
|
||||
|
||||
sink.play();
|
||||
|
||||
{
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
cur.sink = Some(sink);
|
||||
cur.duration_secs = ps.built.duration_secs;
|
||||
cur.seek_offset = snap.position_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
cur.paused_at = None;
|
||||
cur.replay_gain_linear = snap.gain_linear;
|
||||
cur.base_volume = snap.base_volume;
|
||||
cur.fadeout_trigger = Some(ps.built.fadeout_trigger);
|
||||
cur.fadeout_samples = Some(ps.built.fadeout_samples);
|
||||
}
|
||||
|
||||
state.samples_played.store(
|
||||
raw_counter_samples_for_content_position(
|
||||
snap.position_secs,
|
||||
ps.built.output_rate,
|
||||
ps.built.output_channels as u32,
|
||||
&state.playback_rate,
|
||||
),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[hi-res-blend] gapless realigned current track at {blend_rate} Hz from {:.2}s",
|
||||
snap.position_secs
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_rate_inactive_without_hi_res_or_transition() {
|
||||
assert_eq!(blend_rate_hz(false, true, Some(96_000)), None);
|
||||
assert_eq!(blend_rate_hz(true, false, Some(96_000)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_rate_sanitizes_hz() {
|
||||
assert_eq!(blend_rate_hz(true, true, None), Some(44_100));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(88_200)), Some(88_200));
|
||||
assert_eq!(blend_rate_hz(true, true, Some(48_000)), Some(44_100));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ mod power_notify_win;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod power_notify_linux;
|
||||
mod helpers;
|
||||
mod hi_res_blend;
|
||||
mod ipc;
|
||||
pub mod preview;
|
||||
mod sources;
|
||||
|
||||
@@ -33,9 +33,20 @@ pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub done_flag: Arc<AtomicBool>,
|
||||
pub fade_in_dur: Duration,
|
||||
pub hi_res_enabled: bool,
|
||||
/// When > 0, resample decoded audio to this Hz (hi-res crossfade / AutoDJ blend).
|
||||
pub resample_target_hz: u32,
|
||||
pub duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Decoder/output-shaping inputs shared by [`build_source_from_play_input`].
|
||||
struct PlaybackSourceShape {
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
resample_target_hz: u32,
|
||||
duration_hint: f64,
|
||||
}
|
||||
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
@@ -183,6 +194,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = args;
|
||||
let media_hint = play_media_format_hint(&play_input);
|
||||
@@ -196,15 +208,15 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
crate::app_deprintln!("[stream] playback format hint: {h}");
|
||||
}
|
||||
|
||||
match build_source_from_play_input(
|
||||
play_input,
|
||||
state,
|
||||
effective_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
let shape = PlaybackSourceShape {
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
)
|
||||
};
|
||||
|
||||
match build_source_from_play_input(play_input, state, effective_hint.as_deref(), &shape)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Ok(p),
|
||||
@@ -261,10 +273,7 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(data.clone()),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag.clone(),
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
&shape,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -296,10 +305,13 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
PlayInput::Bytes(fresh),
|
||||
state,
|
||||
bytes_hint.as_deref(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
duration_hint,
|
||||
&PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -317,29 +329,32 @@ async fn build_source_from_play_input(
|
||||
play_input: PlayInput,
|
||||
state: &State<'_, AudioEngine>,
|
||||
format_hint: Option<&str>,
|
||||
done_flag: Arc<AtomicBool>,
|
||||
fade_in_dur: Duration,
|
||||
hi_res_enabled: bool,
|
||||
duration_hint: f64,
|
||||
shape: &PlaybackSourceShape,
|
||||
) -> Result<PlaybackSource, String> {
|
||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||
// the output device rate internally; we let every track play at its native rate.
|
||||
let target_rate: u32 = 0;
|
||||
let PlaybackSourceShape {
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
hi_res_enabled,
|
||||
resample_target_hz,
|
||||
duration_hint,
|
||||
} = shape;
|
||||
// 0 = native rate; hi-res crossfade blend passes an explicit Hz.
|
||||
let target_rate: u32 = *resample_target_hz;
|
||||
let mut is_seekable = true;
|
||||
let built = match play_input {
|
||||
PlayInput::Bytes(data) => build_source(
|
||||
data,
|
||||
duration_hint,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
format_hint,
|
||||
hi_res_enabled,
|
||||
*hi_res_enabled,
|
||||
),
|
||||
PlayInput::SeekableMedia {
|
||||
reader,
|
||||
@@ -361,13 +376,13 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
None,
|
||||
@@ -387,13 +402,13 @@ async fn build_source_from_play_input(
|
||||
.map_err(|e| e.to_string())??;
|
||||
build_streaming_source(
|
||||
decoder,
|
||||
duration_hint,
|
||||
*duration_hint,
|
||||
state.eq_gains.clone(),
|
||||
state.eq_enabled.clone(),
|
||||
state.eq_pre_gain.clone(),
|
||||
state.playback_rate.clone(),
|
||||
done_flag,
|
||||
fade_in_dur,
|
||||
done_flag.clone(),
|
||||
*fade_in_dur,
|
||||
state.samples_played.clone(),
|
||||
target_rate,
|
||||
Some(state.stream_playback_armed.clone()),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NormalizationBlock } from './audio/NormalizationBlock';
|
||||
import { PlaybackRateBlock } from './audio/PlaybackRateBlock';
|
||||
import { TrackTransitionsBlock } from './audio/TrackTransitionsBlock';
|
||||
import { TrackPreviewsSection } from './audio/TrackPreviewsSection';
|
||||
import { HiResCrossfadeResampleBlock } from './audio/HiResCrossfadeResampleBlock';
|
||||
|
||||
export function AudioTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -87,6 +88,12 @@ export function AudioTab() {
|
||||
checked={auth.enableHiRes}
|
||||
onChange={auth.setEnableHiRes}
|
||||
/>
|
||||
<HiResCrossfadeResampleBlock
|
||||
enabled={auth.enableHiRes}
|
||||
resampleHz={auth.hiResCrossfadeResampleHz}
|
||||
onResampleHzChange={auth.setHiResCrossfadeResampleHz}
|
||||
t={t}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
HI_RES_CROSSFADE_RESAMPLE_OPTIONS,
|
||||
type HiResCrossfadeResampleHz,
|
||||
sanitizeHiResCrossfadeResampleHz,
|
||||
} from '../../../utils/audio/hiResCrossfadeResample';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { SettingsGroup } from '../SettingsGroup';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
resampleHz: HiResCrossfadeResampleHz;
|
||||
onResampleHzChange: (hz: HiResCrossfadeResampleHz) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function labelForHz(t: TFunction, hz: HiResCrossfadeResampleHz): string {
|
||||
if (hz === 88_200) return t('settings.hiResCrossfadeResample88');
|
||||
if (hz === 96_000) return t('settings.hiResCrossfadeResample96');
|
||||
return t('settings.hiResCrossfadeResample44');
|
||||
}
|
||||
|
||||
/** Hi-Res crossfade / AutoDJ / gapless blend-rate picker (visible when hi-res is on). */
|
||||
export function HiResCrossfadeResampleBlock({
|
||||
enabled,
|
||||
resampleHz,
|
||||
onResampleHzChange,
|
||||
t,
|
||||
}: Props) {
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<SettingsGroup>
|
||||
<p className="settings-row-label" style={{ marginBottom: '0.5rem' }}>
|
||||
{t('settings.hiResCrossfadeResampleTitle')}
|
||||
</p>
|
||||
<p className="settings-row-desc" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.hiResCrossfadeResampleDesc')}
|
||||
</p>
|
||||
<div className="settings-segmented" style={{ marginBottom: '0.75rem' }}>
|
||||
{HI_RES_CROSSFADE_RESAMPLE_OPTIONS.map((hz) => (
|
||||
<button
|
||||
key={hz}
|
||||
type="button"
|
||||
className={`btn ${resampleHz === hz ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => onResampleHzChange(sanitizeHiResCrossfadeResampleHz(hz))}
|
||||
>
|
||||
{labelForHz(t, hz)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="settings-row-desc" role="note" style={{ marginBottom: 0, opacity: 0.85 }}>
|
||||
{t('settings.hiResCrossfadeResampleWarning')}
|
||||
</p>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string;
|
||||
|
||||
export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' },
|
||||
{ tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' },
|
||||
{ tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit crossfade autodj blend 44 88 96' },
|
||||
{ tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
|
||||
{ tab: 'audio', titleKey: 'settings.playbackRateTitle', keywords: 'speed playback rate tempo pitch varispeed preserve corrected time stretch' },
|
||||
{ tab: 'audio', titleKey: 'settings.normalization', keywords: 'normalization normalisation loudness volume leveling level replaygain replay gain lufs pre-gain' },
|
||||
|
||||
@@ -174,6 +174,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'All Albums browse: compilation and favorites filters in slice mode — skip redundant client comp filter, route favorites through getStarred2, pre-index page scan, offline isCompilation from tracks (PR #1151)',
|
||||
'Custom HTTP headers for reverse-proxy gates — per-server header editor, TS/Rust registry sync, full playback/sync/cover path coverage, log redaction (PR #1156)',
|
||||
'CI: ESLint strict workflow and path-aware ci-ok merge gate (PR #1170)',
|
||||
'Hi-Res transition blend rate — configurable 44.1/88.2/96 kHz resampling for crossfade, AutoDJ, and gapless when adjacent tracks differ in sample rate (PR #1171)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -235,6 +235,12 @@ export const settings = {
|
||||
hiResTitle: 'Native Hi-Res-Wiedergabe',
|
||||
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
|
||||
hiResDesc: "Spielt jeden Titel mit seiner ursprünglichen Abtastrate ab, statt alles auf 44,1 kHz herunterzurechnen, und stellt das Ausgabegerät passend zur Datei um (88,2 kHz und höher). Nur aktivieren, wenn Hardware und Netzwerk hohe Abtastraten zuverlässig verarbeiten.",
|
||||
hiResCrossfadeResampleTitle: 'Crossfade / AutoDJ / Gapless Mischrate',
|
||||
hiResCrossfadeResampleDesc: 'Wenn benachbarte Titel unterschiedliche Abtastraten haben, werden beide Seiten für Crossfade-, AutoDJ- und Gapless-Übergänge auf diese Rate resampled.',
|
||||
hiResCrossfadeResample44: '44,1 kHz',
|
||||
hiResCrossfadeResample88: '88,2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'Resampling verbraucht zusätzliche CPU und Speicher — auf langsameren Geräten vorsichtig wählen.',
|
||||
showArtistImages: 'Künstlerbilder anzeigen',
|
||||
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
|
||||
showOrbitTrigger: '„Orbit" im Header zeigen',
|
||||
|
||||
@@ -238,6 +238,12 @@ export const settings = {
|
||||
hiResTitle: 'Native Hi-Res Playback',
|
||||
hiResEnabled: 'Enable native hi-res playback',
|
||||
hiResDesc: "Plays each track at its original sample rate instead of resampling everything to 44.1 kHz, switching the audio device to match the file (88.2 kHz and up). Enable only if your hardware and network reliably handle high sample rates.",
|
||||
hiResCrossfadeResampleTitle: 'Crossfade / AutoDJ / Gapless blend rate',
|
||||
hiResCrossfadeResampleDesc: 'When adjacent tracks have different sample rates, both sides are resampled to this rate during crossfade, AutoDJ, and gapless transitions.',
|
||||
hiResCrossfadeResample44: '44.1 kHz',
|
||||
hiResCrossfadeResample88: '88.2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'Resampling uses extra CPU and memory — choose carefully on slower devices.',
|
||||
showArtistImages: 'Show Artist Images',
|
||||
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
|
||||
showOrbitTrigger: 'Show "Orbit" in the header',
|
||||
|
||||
@@ -234,6 +234,12 @@ export const settings = {
|
||||
hiResTitle: 'Reproducción Nativa Hi-Res',
|
||||
hiResEnabled: 'Habilitar reproducción nativa hi-res',
|
||||
hiResDesc: "Reproduce cada pista a su frecuencia de muestreo original en vez de remuestrear todo a 44.1 kHz, ajustando el dispositivo de salida para que coincida con el archivo (88.2 kHz o más). Habilítalo solo si tu hardware y tu red soportan de forma fiable altas tasas de muestreo.",
|
||||
hiResCrossfadeResampleTitle: 'Frecuencia de mezcla Crossfade / AutoDJ / Gapless',
|
||||
hiResCrossfadeResampleDesc: 'Cuando pistas adyacentes tienen distinta frecuencia de muestreo, ambos lados se remuestrean a esta frecuencia durante crossfade, AutoDJ y transiciones gapless.',
|
||||
hiResCrossfadeResample44: '44,1 kHz',
|
||||
hiResCrossfadeResample88: '88,2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'El remuestreo consume más CPU y memoria — elige con cuidado en dispositivos lentos.',
|
||||
showArtistImages: 'Mostrar Imágenes de Artistas',
|
||||
showArtistImagesDesc: 'Carga y muestra imágenes de artistas en el resumen de Artistas. Desactivado por defecto para reducir I/O de disco del servidor y carga de red en bibliotecas grandes.',
|
||||
showOrbitTrigger: 'Mostrar "Orbit" en la cabecera',
|
||||
|
||||
@@ -234,6 +234,12 @@ export const settings = {
|
||||
hiResTitle: 'Lecture haute résolution native',
|
||||
hiResEnabled: 'Activer la lecture haute résolution native',
|
||||
hiResDesc: "Lit chaque piste à sa fréquence d'échantillonnage d'origine au lieu de tout rééchantillonner à 44,1 kHz, en réglant le périphérique de sortie sur celle du fichier (88,2 kHz et plus). N'activer que si le matériel et le réseau gèrent ces hautes fréquences de façon fiable.",
|
||||
hiResCrossfadeResampleTitle: 'Fréquence de mixage Crossfade / AutoDJ / Gapless',
|
||||
hiResCrossfadeResampleDesc: 'Lorsque des pistes voisines ont des fréquences d\'échantillonnage différentes, les deux côtés sont rééchantillonnés à cette fréquence pendant le crossfade, AutoDJ et les transitions gapless.',
|
||||
hiResCrossfadeResample44: '44,1 kHz',
|
||||
hiResCrossfadeResample88: '88,2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'Le rééchantillonnage utilise davantage de CPU et de mémoire — choisissez avec prudence sur les appareils lents.',
|
||||
showArtistImages: 'Afficher les images d\'artistes',
|
||||
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
|
||||
showOrbitTrigger: 'Afficher « Orbit » dans l\'en-tête',
|
||||
|
||||
@@ -238,6 +238,12 @@ export const settings = {
|
||||
hiResTitle: 'Natív Hi-Res lejátszás',
|
||||
hiResEnabled: 'Natív Hi-Res lejátszás engedélyezése',
|
||||
hiResDesc: 'Minden számot az eredeti mintavételezési frekvenciáján játszik le, ahelyett, hogy mindent 44,1 kHz-re mintavételezne újra, és a hangeszközt a fájlhoz igazítja (88,2 kHz-től felfelé). Csak akkor engedélyezd, ha a hardvered és a hálózatod megbízhatóan kezeli a magas mintavételezési frekvenciákat.',
|
||||
hiResCrossfadeResampleTitle: 'Crossfade / AutoDJ / Gapless keverési frekvencia',
|
||||
hiResCrossfadeResampleDesc: 'Ha a szomszédos számok mintavételezési frekvenciája eltér, mindkét oldal erre a frekvenciára kerül átmintavételezésre crossfade, AutoDJ és gapless átmeneteknél.',
|
||||
hiResCrossfadeResample44: '44,1 kHz',
|
||||
hiResCrossfadeResample88: '88,2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'Az átmintavételezés extra CPU-t és memóriát igényel — lassabb eszközökön válassz körültekintően.',
|
||||
showArtistImages: 'Előadói képek megjelenítése',
|
||||
showArtistImagesDesc: 'Az előadói képek betöltése és megjelenítése az Előadók áttekintőben. Alapértelmezetten letiltva a szerver lemez-I/O és hálózati terhelésének csökkentése érdekében nagy könyvtáraknál.',
|
||||
showOrbitTrigger: 'Az „Orbit" megjelenítése a fejlécben',
|
||||
|
||||
@@ -238,6 +238,12 @@ export const settings = {
|
||||
hiResTitle: 'ネイティブ Hi-Res 再生',
|
||||
hiResEnabled: 'ネイティブ Hi-Res 再生を有効化',
|
||||
hiResDesc: 'すべてを 44.1 kHz にリサンプリングせず、各トラックを元のサンプルレートで再生し、ファイルに合わせて音声デバイスを切り替えます (88.2 kHz 以上)。高サンプルレートをハードウェアとネットワークが安定して扱える場合のみ有効にしてください。',
|
||||
hiResCrossfadeResampleTitle: 'クロスフェード / AutoDJ / Gapless ブレンドレート',
|
||||
hiResCrossfadeResampleDesc: '隣接トラックのサンプルレートが異なる場合、クロスフェード・AutoDJ・ギャップレス遷移中は両方をこのレートにリサンプリングします。',
|
||||
hiResCrossfadeResample44: '44.1 kHz',
|
||||
hiResCrossfadeResample88: '88.2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'リサンプリングは CPU とメモリを追加で消費します。低速デバイスでは慎重に選んでください。',
|
||||
showArtistImages: 'アーティスト画像を表示',
|
||||
showArtistImagesDesc: 'アーティスト一覧でアーティスト画像を読み込んで表示します。大規模ライブラリでサーバーディスク I/O とネットワーク負荷を減らすため、既定では無効です。',
|
||||
showOrbitTrigger: 'ヘッダーに "Orbit" を表示',
|
||||
|
||||
@@ -235,6 +235,12 @@ export const settings = {
|
||||
hiResTitle: 'Innebygd hi-res-avspilling',
|
||||
hiResEnabled: 'Aktiver innebygd hi-res-avspilling',
|
||||
hiResDesc: "Spiller hvert spor med sin opprinnelige samplingsrate i stedet for å nedsample alt til 44,1 kHz, og stiller utdataenheten etter filen (88,2 kHz og høyere). Aktiver kun hvis maskinvare og nettverk håndterer høye samplingsrater pålitelig.",
|
||||
hiResCrossfadeResampleTitle: 'Crossfade / AutoDJ / Gapless blandingsrate',
|
||||
hiResCrossfadeResampleDesc: 'Når nabospor har ulik samplingsrate, resamples begge sider til denne raten under crossfade-, AutoDJ- og gapless-overganger.',
|
||||
hiResCrossfadeResample44: '44,1 kHz',
|
||||
hiResCrossfadeResample88: '88,2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'Resampling bruker ekstra CPU og minne — velg forsiktig på tregere enheter.',
|
||||
showArtistImages: 'Vis artistbilder',
|
||||
showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.',
|
||||
showOrbitTrigger: 'Vis «Orbit» i toppen',
|
||||
|
||||
@@ -234,6 +234,12 @@ export const settings = {
|
||||
hiResTitle: 'Natieve hi-res-weergave',
|
||||
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
|
||||
hiResDesc: "Speelt elke track af op zijn oorspronkelijke samplerate in plaats van alles te herbemonsteren naar 44,1 kHz, en stelt het uitvoerapparaat af op het bestand (88,2 kHz en hoger). Alleen inschakelen als hardware en netwerk hoge samplerates betrouwbaar aankunnen.",
|
||||
hiResCrossfadeResampleTitle: 'Crossfade / AutoDJ / Gapless mengfrequentie',
|
||||
hiResCrossfadeResampleDesc: 'Als aangrenzende tracks verschillende samplerates hebben, worden beide kanten naar deze frequentie geresampled tijdens crossfade-, AutoDJ- en gapless-overgangen.',
|
||||
hiResCrossfadeResample44: '44,1 kHz',
|
||||
hiResCrossfadeResample88: '88,2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'Resampling kost extra CPU en geheugen — kies voorzichtig op tragere apparaten.',
|
||||
showArtistImages: 'Artiestafbeeldingen weergeven',
|
||||
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
|
||||
showOrbitTrigger: '"Orbit" in de kop tonen',
|
||||
|
||||
@@ -237,6 +237,12 @@ export const settings = {
|
||||
hiResTitle: 'Playback Hi-Res nativ',
|
||||
hiResEnabled: 'Pornește playback-ul hi-res nativ',
|
||||
hiResDesc: "Redă fiecare piesă la rata sa de eșantionare originală în loc să reeșantioneze totul la 44.1 kHz, comutând dispozitivul de ieșire pentru a se potrivi cu fișierul (88.2 kHz și peste). Pornește doar dacă hardware-ul și rețeaua gestionează fiabil rate mari de eșantionare.",
|
||||
hiResCrossfadeResampleTitle: 'Rată de mixaj Crossfade / AutoDJ / Gapless',
|
||||
hiResCrossfadeResampleDesc: 'Când piesele adiacente au rate de eșantionare diferite, ambele părți sunt reeșantionate la această rată în timpul crossfade-ului, AutoDJ și tranzițiilor gapless.',
|
||||
hiResCrossfadeResample44: '44,1 kHz',
|
||||
hiResCrossfadeResample88: '88,2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: 'Reeșantionarea consumă CPU și memorie suplimentară — alege cu atenție pe dispozitive mai lente.',
|
||||
showArtistImages: 'Afișează Imagini Artist',
|
||||
showArtistImagesDesc: 'Încarcă și afișează imagini artist în Prezentarea generală a Artiștilor. Oprit implicit pentru a reduce I/O pe server și încărcarea rețelei pe librării mari.',
|
||||
showOrbitTrigger: 'Afișează "Orbit" în antet',
|
||||
|
||||
@@ -238,6 +238,12 @@ export const settings = {
|
||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||
hiResDesc: "Воспроизводит каждый трек с его исходной частотой дискретизации вместо передискретизации всего в 44,1 кГц, переключая устройство вывода под частоту файла (88,2 кГц и выше). Включайте только если оборудование и сеть надёжно справляются с высокими частотами.",
|
||||
hiResCrossfadeResampleTitle: 'Частота сведения Crossfade / AutoDJ / Gapless',
|
||||
hiResCrossfadeResampleDesc: 'Когда соседние треки имеют разную частоту дискретизации, обе стороны передискретизируются на эту частоту во время кроссфейда, AutoDJ и gapless-переходов.',
|
||||
hiResCrossfadeResample44: '44,1 кГц',
|
||||
hiResCrossfadeResample88: '88,2 кГц',
|
||||
hiResCrossfadeResample96: '96 кГц',
|
||||
hiResCrossfadeResampleWarning: 'Передискретизация увеличивает нагрузку на CPU и память — на слабых устройствах выбирайте с осторожностью.',
|
||||
showArtistImages: 'Фото исполнителей',
|
||||
showArtistImagesDesc:
|
||||
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
|
||||
|
||||
@@ -234,6 +234,12 @@ export const settings = {
|
||||
hiResTitle: '原生高清晰度播放',
|
||||
hiResEnabled: '启用原生高清晰度播放',
|
||||
hiResDesc: "以每个曲目的原始采样率播放,而不是将所有内容重采样到 44.1 kHz,并将输出设备切换为与文件匹配(88.2 kHz 及以上)。仅在硬件和网络能可靠处理高采样率时启用。",
|
||||
hiResCrossfadeResampleTitle: '交叉淡化 / AutoDJ / Gapless 混合采样率',
|
||||
hiResCrossfadeResampleDesc: '当相邻曲目的采样率不同时,在交叉淡化、AutoDJ 和无缝过渡期间将两侧重采样到此采样率。',
|
||||
hiResCrossfadeResample44: '44.1 kHz',
|
||||
hiResCrossfadeResample88: '88.2 kHz',
|
||||
hiResCrossfadeResample96: '96 kHz',
|
||||
hiResCrossfadeResampleWarning: '重采样会额外消耗 CPU 和内存——在较慢设备上请谨慎选择。',
|
||||
showArtistImages: '显示艺术家图片',
|
||||
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
|
||||
showOrbitTrigger: '在顶栏显示"Orbit"',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '../utils/audio/hiResCrossfadeResample';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { getPlayGeneration, setIsAudioPaused } from './engineState';
|
||||
@@ -444,7 +445,7 @@ export function handleAudioProgress(
|
||||
loudnessGainDb: loudnessGainDbForEngineBind(nextTrack.id),
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
...audioPlayHiResBlendArgs(authState),
|
||||
analysisTrackId: nextTrack.id,
|
||||
serverId: analysisServerId || null,
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -31,6 +31,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
| 'setAutodjSmoothSkip'
|
||||
| 'setGaplessEnabled'
|
||||
| 'setEnableHiRes'
|
||||
| 'setHiResCrossfadeResampleHz'
|
||||
| 'setAudioOutputDevice'
|
||||
> {
|
||||
return {
|
||||
@@ -73,6 +74,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
|
||||
setAutodjSmoothSkip: (v) => set({ autodjSmoothSkip: v }),
|
||||
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
|
||||
setEnableHiRes: (v) => set({ enableHiRes: v }),
|
||||
setHiResCrossfadeResampleHz: (v) => set({ hiResCrossfadeResampleHz: v }),
|
||||
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('trivial pass-through setters', () => {
|
||||
['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true],
|
||||
['setQueueDurationDisplayMode', 'queueDurationDisplayMode', 'eta'],
|
||||
['setEnableHiRes', 'enableHiRes', true],
|
||||
['setHiResCrossfadeResampleHz', 'hiResCrossfadeResampleHz', 88_200],
|
||||
['setFavoritesOfflineEnabled', 'favoritesOfflineEnabled', true],
|
||||
['setHotCacheEnabled', 'hotCacheEnabled', true],
|
||||
['setMixMinRatingFilterEnabled', 'mixMinRatingFilterEnabled', true],
|
||||
|
||||
@@ -100,6 +100,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
queueDurationDisplayMode: 'total',
|
||||
queueDisplayMode: 'queue',
|
||||
enableHiRes: false,
|
||||
hiResCrossfadeResampleHz: 44_100,
|
||||
audioOutputDevice: null,
|
||||
favoritesOfflineEnabled: false,
|
||||
hotCacheEnabled: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
import { sanitizeHiResCrossfadeResampleHz } from '../utils/audio/hiResCrossfadeResample';
|
||||
import {
|
||||
LOUDNESS_PRE_ANALYSIS_REF_TARGET_LUFS,
|
||||
clampStoredLoudnessPreAnalysisAttenuationRefDb,
|
||||
@@ -244,6 +245,9 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
loudnessTargetLufs: targetSan,
|
||||
loudnessPreAnalysisAttenuationDb: preSan,
|
||||
loudnessPreIsRefV1: true,
|
||||
hiResCrossfadeResampleHz: sanitizeHiResCrossfadeResampleHz(
|
||||
(state as { hiResCrossfadeResampleHz?: unknown }).hiResCrossfadeResampleHz,
|
||||
),
|
||||
...lyricsSourcesMigrated,
|
||||
...youLyPlusMigrated,
|
||||
...wheelSmoothOneTime,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HiResCrossfadeResampleHz } from '../utils/audio/hiResCrossfadeResample';
|
||||
import type { EntityRatingSupportLevel } from '../api/subsonicTypes';
|
||||
import type {
|
||||
AudiomusePluginProbeResult,
|
||||
@@ -242,6 +243,8 @@ export interface AuthState {
|
||||
|
||||
/** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */
|
||||
enableHiRes: boolean;
|
||||
/** Hi-Res: common output rate for crossfade / AutoDJ when adjacent tracks differ (Hz). */
|
||||
hiResCrossfadeResampleHz: HiResCrossfadeResampleHz;
|
||||
/** Selected audio output device name. null = system default. */
|
||||
audioOutputDevice: string | null;
|
||||
|
||||
@@ -416,6 +419,7 @@ export interface AuthState {
|
||||
setQueueDurationDisplayMode: (v: DurationMode) => void;
|
||||
setQueueDisplayMode: (v: QueueDisplayMode) => void;
|
||||
setEnableHiRes: (v: boolean) => void;
|
||||
setHiResCrossfadeResampleHz: (v: HiResCrossfadeResampleHz) => void;
|
||||
setAudioOutputDevice: (v: string | null) => void;
|
||||
setFavoritesOfflineEnabled: (v: boolean) => void;
|
||||
setHotCacheEnabled: (v: boolean) => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '../utils/audio/hiResCrossfadeResample';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { getPlayGeneration, setIsAudioPaused } from './engineState';
|
||||
import { touchHotCacheOnPlayback } from './hotCacheTouch';
|
||||
@@ -56,7 +57,7 @@ export function engineLoadTrackAtPosition(opts: {
|
||||
preGainDb: authState.replayGainPreGainDb,
|
||||
fallbackDb: authState.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: authState.enableHiRes,
|
||||
...audioPlayHiResBlendArgs(authState),
|
||||
analysisTrackId: track.id,
|
||||
serverId: playbackIndexKey || null,
|
||||
streamFormatSuffix: track.suffix ?? null,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../utils/offline/offlineLibraryHelpers';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '../utils/audio/hiResCrossfadeResample';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition, peekArmedCrossfadeDynamicOverlap } from './crossfadeTrimCache';
|
||||
import {
|
||||
@@ -468,7 +469,7 @@ export function runPlayTrack(
|
||||
preGainDb: authStateNow.replayGainPreGainDb,
|
||||
fallbackDb: authStateNow.replayGainFallbackDb,
|
||||
manual,
|
||||
hiResEnabled: authStateNow.enableHiRes,
|
||||
...audioPlayHiResBlendArgs(authStateNow),
|
||||
analysisTrackId: scopedTrack.id,
|
||||
serverId: getPlaybackIndexKey() || null,
|
||||
streamFormatSuffix: scopedTrack.suffix ?? null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '../utils/audio/hiResCrossfadeResample';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { useAuthStore } from './authStore';
|
||||
import {
|
||||
@@ -184,7 +185,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
...audioPlayHiResBlendArgs(useAuthStore.getState()),
|
||||
analysisTrackId: trackToPlay.id,
|
||||
serverId: coldServerId || null,
|
||||
streamFormatSuffix: trackToPlay.suffix ?? null,
|
||||
@@ -225,7 +226,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
preGainDb: authStateCold.replayGainPreGainDb,
|
||||
fallbackDb: authStateCold.replayGainFallbackDb,
|
||||
manual: false,
|
||||
hiResEnabled: useAuthStore.getState().enableHiRes,
|
||||
...audioPlayHiResBlendArgs(useAuthStore.getState()),
|
||||
analysisTrackId: currentTrack.id,
|
||||
serverId: coldServerId || null,
|
||||
streamFormatSuffix: currentTrack.suffix ?? null,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_HI_RES_CROSSFADE_RESAMPLE_HZ,
|
||||
audioPlayHiResBlendArgs,
|
||||
sanitizeHiResCrossfadeResampleHz,
|
||||
} from './hiResCrossfadeResample';
|
||||
|
||||
describe('hiResCrossfadeResample', () => {
|
||||
it('defaults unknown values to 44.1 kHz', () => {
|
||||
expect(sanitizeHiResCrossfadeResampleHz(undefined)).toBe(44_100);
|
||||
expect(sanitizeHiResCrossfadeResampleHz(48_000)).toBe(44_100);
|
||||
});
|
||||
|
||||
it('keeps allowed blend rates', () => {
|
||||
expect(sanitizeHiResCrossfadeResampleHz(88_200)).toBe(88_200);
|
||||
expect(sanitizeHiResCrossfadeResampleHz(96_000)).toBe(96_000);
|
||||
});
|
||||
|
||||
it('omits blend rate when hi-res is off', () => {
|
||||
expect(
|
||||
audioPlayHiResBlendArgs({
|
||||
enableHiRes: false,
|
||||
hiResCrossfadeResampleHz: DEFAULT_HI_RES_CROSSFADE_RESAMPLE_HZ,
|
||||
}),
|
||||
).toEqual({ hiResEnabled: false, hiResCrossfadeResampleHz: null });
|
||||
});
|
||||
|
||||
it('forwards blend rate when hi-res is on', () => {
|
||||
expect(
|
||||
audioPlayHiResBlendArgs({ enableHiRes: true, hiResCrossfadeResampleHz: 96_000 }),
|
||||
).toEqual({ hiResEnabled: true, hiResCrossfadeResampleHz: 96_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Hi-Res transition blend output rates (Hz) for crossfade, AutoDJ, and gapless. */
|
||||
export const HI_RES_CROSSFADE_RESAMPLE_OPTIONS = [44_100, 88_200, 96_000] as const;
|
||||
|
||||
export type HiResCrossfadeResampleHz = (typeof HI_RES_CROSSFADE_RESAMPLE_OPTIONS)[number];
|
||||
|
||||
export const DEFAULT_HI_RES_CROSSFADE_RESAMPLE_HZ: HiResCrossfadeResampleHz = 44_100;
|
||||
|
||||
export function sanitizeHiResCrossfadeResampleHz(value: unknown): HiResCrossfadeResampleHz {
|
||||
if (value === 88_200 || value === 96_000) return value;
|
||||
return DEFAULT_HI_RES_CROSSFADE_RESAMPLE_HZ;
|
||||
}
|
||||
|
||||
/** Args forwarded to `audio_play` for hi-res + crossfade blend rate. */
|
||||
export function audioPlayHiResBlendArgs(auth: {
|
||||
enableHiRes: boolean;
|
||||
hiResCrossfadeResampleHz: HiResCrossfadeResampleHz;
|
||||
}): {
|
||||
hiResEnabled: boolean;
|
||||
hiResCrossfadeResampleHz: number | null;
|
||||
} {
|
||||
return {
|
||||
hiResEnabled: auth.enableHiRes,
|
||||
hiResCrossfadeResampleHz: auth.enableHiRes ? auth.hiResCrossfadeResampleHz : null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user