forked from Keelrleek/SoulStream
feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP.
This commit is contained in:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2834
-178
No files matched your search
@@ -75,6 +75,7 @@ pub struct Settings {
|
||||
pub stream_cache_max_mb: u64,
|
||||
pub stream_source_prefs: config::SourcePrefs,
|
||||
pub download_source_prefs: config::SourcePrefs,
|
||||
pub volume_normalization: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -112,6 +113,7 @@ pub async fn get_settings(
|
||||
stream_cache_max_mb: config::stream_cache_max_mb(&cfg),
|
||||
stream_source_prefs: cfg.stream_source_prefs,
|
||||
download_source_prefs: cfg.download_source_prefs,
|
||||
volume_normalization: config::volume_normalization(&cfg),
|
||||
username: cfg.username,
|
||||
})
|
||||
}
|
||||
@@ -138,6 +140,7 @@ pub async fn set_settings(
|
||||
stream_cache_max_mb: Option<u64>,
|
||||
stream_source_prefs: Option<config::SourcePrefs>,
|
||||
download_source_prefs: Option<config::SourcePrefs>,
|
||||
volume_normalization: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = config::load(&app);
|
||||
if let Some(d) = download_dir {
|
||||
@@ -197,6 +200,12 @@ pub async fn set_settings(
|
||||
if stream_cache_max_mb.is_some() {
|
||||
cfg.stream_cache_max_mb = stream_cache_max_mb;
|
||||
}
|
||||
if let Some(enabled) = volume_normalization {
|
||||
cfg.volume_normalization = Some(enabled);
|
||||
// Applies live: off glides the current track back to unity; on
|
||||
// takes effect from the next track.
|
||||
crate::loudness::set_enabled(&app, enabled);
|
||||
}
|
||||
config::save(&app, &cfg);
|
||||
|
||||
// Tighter limits take effect right away, not at the next stream/startup.
|
||||
@@ -538,6 +547,12 @@ pub async fn connect_and_login(
|
||||
}),
|
||||
);
|
||||
if matches!(&update.state, DS::Complete { .. }) {
|
||||
// Gapless normalization: measure the next
|
||||
// track now so its gain is ready at the
|
||||
// boundary.
|
||||
if let Some(c) = &cache {
|
||||
crate::loudness::on_preload_ready(&event_app, c);
|
||||
}
|
||||
// Only explicit saves reach the library —
|
||||
// unless streamed tracks are configured to
|
||||
// be kept forever.
|
||||
@@ -3272,7 +3287,20 @@ pub async fn play_stream(
|
||||
if let Ok(md) = std::fs::metadata(&cache) {
|
||||
if size > 0 && md.len() == size {
|
||||
playback::touch(&cache);
|
||||
players.player.play_local(&cache, duration);
|
||||
crate::diagnostics::clear_stream(&app);
|
||||
let app2 = app.clone();
|
||||
let cache2 = cache.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
&cache,
|
||||
size,
|
||||
None,
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>()
|
||||
.player
|
||||
.play_local(&cache2, duration);
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::remove_file(&cache);
|
||||
@@ -3300,9 +3328,21 @@ pub async fn play_stream(
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id, cache.clone());
|
||||
players
|
||||
.player
|
||||
.play_stream(&partial, progress, size, duration);
|
||||
crate::diagnostics::note_stream(&app, size, progress.clone());
|
||||
let app2 = app.clone();
|
||||
let partial2 = partial.clone();
|
||||
let progress2 = progress.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
&cache,
|
||||
size,
|
||||
Some((partial, progress)),
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>()
|
||||
.player
|
||||
.play_stream(&partial2, progress2, size, duration);
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -3321,9 +3361,21 @@ pub async fn play_stream(
|
||||
.unwrap()
|
||||
.insert(id, cache.clone());
|
||||
|
||||
players
|
||||
.player
|
||||
.play_stream(&partial, progress, size, duration);
|
||||
crate::diagnostics::note_stream(&app, size, progress.clone());
|
||||
let app2 = app.clone();
|
||||
let partial2 = partial.clone();
|
||||
let progress2 = progress.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
&cache,
|
||||
size,
|
||||
Some((partial, progress)),
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>()
|
||||
.player
|
||||
.play_stream(&partial2, progress2, size, duration);
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3342,6 +3394,8 @@ pub async fn preload_stream(
|
||||
let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?;
|
||||
if let Ok(md) = std::fs::metadata(&cache) {
|
||||
if size > 0 && md.len() == size {
|
||||
// Gapless normalization: boundary gain ready before the switch.
|
||||
crate::loudness::on_preload_ready(&app, &cache);
|
||||
return Ok(()); // already fully cached
|
||||
}
|
||||
let _ = std::fs::remove_file(&cache);
|
||||
@@ -3383,6 +3437,8 @@ pub fn preload_cached_stream(
|
||||
Ok(md) if size == 0 || md.len() == size => {
|
||||
playback::touch(&cache);
|
||||
players.player.preload_local(&cache, duration);
|
||||
// Gapless normalization: boundary gain ready before the switch.
|
||||
crate::loudness::on_preload_ready(&app, &cache);
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
@@ -3485,14 +3541,25 @@ pub async fn stream_waveform(
|
||||
/// Play an already-downloaded local file.
|
||||
#[tauri::command]
|
||||
pub fn play_local(
|
||||
players: State<'_, PlayerState>,
|
||||
app: tauri::AppHandle,
|
||||
path: String,
|
||||
duration: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
if std::fs::metadata(&path).is_err() {
|
||||
return Err("file not found".into());
|
||||
}
|
||||
players.player.play_local(&path, duration);
|
||||
crate::diagnostics::clear_stream(&app);
|
||||
let app2 = app.clone();
|
||||
let path2 = path.clone();
|
||||
crate::loudness::on_track_started(
|
||||
&app,
|
||||
std::path::Path::new(&path),
|
||||
0,
|
||||
None,
|
||||
Box::new(move || {
|
||||
app2.state::<PlayerState>().player.play_local(&path2, duration);
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3500,6 +3567,7 @@ pub fn play_local(
|
||||
/// used for gapless album playback; it has no effect on streaming downloads.
|
||||
#[tauri::command]
|
||||
pub fn preload_local(
|
||||
app: tauri::AppHandle,
|
||||
players: State<'_, PlayerState>,
|
||||
path: String,
|
||||
duration: Option<f64>,
|
||||
@@ -3508,6 +3576,8 @@ pub fn preload_local(
|
||||
return Err("file not found".into());
|
||||
}
|
||||
players.player.preload_local(&path, duration);
|
||||
// Gapless normalization: have the boundary gain ready before the switch.
|
||||
crate::loudness::on_preload_ready(&app, std::path::Path::new(&path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user