From c6fc3ec844c33d861ec3a9681024cd52d06662d4 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Sun, 26 Apr 2026 00:49:10 +0300 Subject: [PATCH] fix(waveform): stabilize progressive rendering and reduce streaming contention Improve seekbar fallback behavior by using consistent bar-based rendering and smoother transitions to analyzed waveform data. Reduce hot-path analysis overhead during ranged streaming and add controls to clear cached waveform entries. --- src-tauri/src/analysis_cache.rs | 327 +++++++++++++++++++++++++------- src-tauri/src/audio.rs | 59 ++++-- src-tauri/src/lib.rs | 8 + src/components/WaveformSeek.tsx | 163 +++++++++++----- src/locales/en.ts | 3 + src/locales/ru.ts | 3 + src/pages/Settings.tsx | 34 ++++ src/store/playerStore.ts | 57 ++++-- 8 files changed, 504 insertions(+), 150 deletions(-) diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index ac2ab4fa..f3a57486 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -14,7 +14,7 @@ use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use tauri::Manager; -pub const WAVEFORM_ALGO_VERSION: i64 = 1; +pub const WAVEFORM_ALGO_VERSION: i64 = 3; pub const LOUDNESS_ALGO_VERSION: i64 = 1; #[derive(Debug, Clone)] @@ -102,6 +102,18 @@ impl AnalysisCache { Ok(total) } + /// Remove all cached waveform rows across all tracks/variants. + pub fn delete_all_waveforms(&self) -> Result { + let conn = self + .conn + .lock() + .map_err(|_| "analysis_cache lock poisoned".to_string())?; + let n = conn + .execute("DELETE FROM waveform_cache", []) + .map_err(|e| e.to_string())?; + Ok(n as u64) + } + pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> { let now = now_unix_ts(); let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; @@ -313,8 +325,20 @@ pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> }; cache.touch_track_status(&key, "queued")?; + let wf_bins: Vec; + let loudness_opt: Option<(f64, f64, f64, f64)>; + match analyze_loudness_and_waveform(bytes, -16.0, 500) { + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => { + wf_bins = bins; + loudness_opt = Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)); + } + None => { + wf_bins = derive_waveform_bins(bytes, 500); + loudness_opt = None; + } + } let waveform = WaveformEntry { - bins: derive_waveform_bins(bytes, 500), + bins: wf_bins, bin_count: 500, is_partial: false, known_until_sec: 0.0, @@ -323,9 +347,7 @@ pub fn seed_from_bytes(app: &tauri::AppHandle, track_id: &str, bytes: &[u8]) -> }; cache.upsert_waveform(&key, &waveform)?; - if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = - analyze_loudness_from_audio_bytes(bytes, -16.0) - { + if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt { let loudness = LoudnessEntry { integrated_lufs, true_peak, @@ -372,11 +394,48 @@ fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec { out } -fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(f64, f64, f64, f64)> { - if bytes.is_empty() { +struct PcmScanResult { + bins: Vec, + loudness: Option<(f64, f64, f64, f64)>, +} + +/// Decoded PCM peak envelope (mono mix) → `bin_count` bars, for seekbar display. +/// Two decode passes: count frames, then fill bins. Returns `None` if probing/decoding fails. +pub fn derive_waveform_bins_from_audio_bytes(bytes: &[u8], bin_count: usize) -> Option> { + if bytes.is_empty() || bin_count == 0 { return None; } + let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?; + if decoded_frames == 0 { + return None; + } + let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, None)?; + Some(scanned.bins) +} +/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count. +fn analyze_loudness_and_waveform( + bytes: &[u8], + target_lufs: f64, + bin_count: usize, +) -> Option<(f64, f64, f64, f64, Vec)> { + if bytes.is_empty() || bin_count == 0 { + return None; + } + let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?; + if decoded_frames == 0 { + return None; + } + let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?; + let (i, t, r, tgt) = scanned.loudness?; + Some((i, t, r, tgt, scanned.bins)) +} + +/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is +/// `codec_params.n_frames` when the container reports total track length — used +/// as a **fixed** waveform time axis so partial decodes do not remap every bin +/// when the buffer grows. +fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option)> { let source = Box::new(Cursor::new(bytes.to_vec())); let mss = MediaSourceStream::new(source, Default::default()); let hint = Hint::new(); @@ -388,33 +447,128 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( .default_track() .filter(|t| t.codec_params.codec != CODEC_TYPE_NULL) .or_else(|| { - format - .tracks() - .iter() - .find(|t| { - t.codec_params.codec != CODEC_TYPE_NULL - && t.codec_params.sample_rate.is_some() - && t.codec_params.channels.is_some() - }) + format.tracks().iter().find(|t| { + t.codec_params.codec != CODEC_TYPE_NULL + && t.codec_params.sample_rate.is_some() + && t.codec_params.channels.is_some() + }) + }) + .or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?; + let track_id = track.id; + let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0); + let codec_params = track.codec_params.clone(); + let mut decoder = symphonia::default::get_codecs() + .make(&codec_params, &DecoderOptions::default()) + .ok()?; + + let mut total: u64 = 0; + let mut loop_i: u32 = 0; + loop { + let packet = match format.next_packet() { + Ok(packet) => packet, + Err(_) => break, + }; + if packet.track_id() != track_id { + continue; + } + let decoded = match decoder.decode(&packet) { + Ok(buf) => buf, + Err(SymphoniaError::DecodeError(_)) => continue, + Err(SymphoniaError::ResetRequired) => break, + Err(_) => break, + }; + let spec = *decoded.spec(); + let n_ch = spec.channels.count(); + if n_ch == 0 { + continue; + } + let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); + samples.copy_interleaved_ref(decoded); + let n = samples.samples().len(); + if n < n_ch || n % n_ch != 0 { + continue; + } + total += (n / n_ch) as u64; + loop_i = loop_i.wrapping_add(1); + if loop_i % 128 == 0 { + std::thread::yield_now(); + } + } + if total == 0 { + None + } else { + Some((total, timeline_hint)) + } +} + +fn normalize_peak_bins(bin_max: &[f32]) -> Vec { + let bin_count = bin_max.len(); + if bin_count == 0 { + return Vec::new(); + } + let mut sorted: Vec = bin_max.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))]; + let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))]; + let range = (p99 - p5).max(1e-8); + let mut out = vec![0u8; bin_count]; + for i in 0..bin_count { + let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0); + let shaped = t.powf(0.52); + out[i] = (8.0 + shaped * 247.0).min(255.0) as u8; + } + out +} + +fn decode_scan_pcm( + bytes: &[u8], + bin_count: usize, + decoded_frames: u64, + timeline_hint: Option, + loudness_target_lufs: Option, +) -> Option { + let source = Box::new(Cursor::new(bytes.to_vec())); + let mss = MediaSourceStream::new(source, Default::default()); + let hint = Hint::new(); + let probed = symphonia::default::get_probe() + .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) + .ok()?; + let mut format = probed.format; + let track = format + .default_track() + .filter(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .or_else(|| { + format.tracks().iter().find(|t| { + t.codec_params.codec != CODEC_TYPE_NULL + && t.codec_params.sample_rate.is_some() + && t.codec_params.channels.is_some() + }) }) .or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?; let track_id = track.id; let codec_params = track.codec_params.clone(); - - let mut decoder = match symphonia::default::get_codecs() - .make(&codec_params, &DecoderOptions::default()) - { + let mut decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) { Ok(v) => v, Err(e) => { crate::app_deprintln!("[analysis] decoder make failed: {}", e); return None; } }; + + let mut bin_max = vec![0.0f32; bin_count]; let mut ebu: Option = None; let mut ebu_channels: u32 = 0; let mut sample_peak_abs = 0.0_f64; let mut fed_any_frames = false; + let mut sample_idx: u64 = 0; let mut loop_i: u32 = 0; + // Fixed timeline from metadata when available; otherwise fall back to decoded + // length (full-buffer analysis only — partial byte windows still shift, but + // then we usually lack n_frames anyway). + let bin_grid_frames = timeline_hint + .map(|n| n.max(decoded_frames)) + .unwrap_or(decoded_frames) + .max(1); loop { let packet = match format.next_packet() { @@ -424,7 +578,6 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( if packet.track_id() != track_id { continue; } - let decoded = match decoder.decode(&packet) { Ok(buf) => buf, Err(SymphoniaError::DecodeError(_)) => continue, @@ -433,7 +586,12 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( }; let spec = *decoded.spec(); - if ebu.is_none() { + let n_ch = spec.channels.count(); + if n_ch == 0 { + continue; + } + + if loudness_target_lufs.is_some() && ebu.is_none() { let ch = spec.channels.count() as u32; let sr = spec.rate; match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) { @@ -452,70 +610,101 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<( } } } + let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); samples.copy_interleaved_ref(decoded); - for &s in samples.samples() { - let v = (s as f64).abs(); - if v.is_finite() && v > sample_peak_abs { - sample_peak_abs = v; - } - } - let Some(ref mut ebu) = ebu else { - crate::app_deprintln!("[analysis] loudness failed: ebu not initialized"); - return None; - }; - match ebu.add_frames_f32(samples.samples()) { - Ok(_) => fed_any_frames = true, - Err(e) => { - crate::app_deprintln!("[analysis] loudness add_frames failed: {}", e); - return None; + let slice = samples.samples(); + if slice.len() < n_ch || slice.len() % n_ch != 0 { + continue; + } + let frames = slice.len() / n_ch; + + for f in 0..frames { + let base = f * n_ch; + let mut acc = 0.0f32; + for c in 0..n_ch { + acc += slice[base + c]; + } + let mono = acc / (n_ch as f32); + let mag = mono.abs(); + if mag.is_finite() { + let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize; + let bin = bin.min(bin_count.saturating_sub(1)); + bin_max[bin] = bin_max[bin].max(mag); + } + for c in 0..n_ch { + let v = (slice[base + c] as f64).abs(); + if v.is_finite() && v > sample_peak_abs { + sample_peak_abs = v; + } + } + sample_idx += 1; + } + + if loudness_target_lufs.is_some() { + if let Some(e) = ebu.as_mut() { + match e.add_frames_f32(samples.samples()) { + Ok(_) => fed_any_frames = true, + Err(err) => { + crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err); + return None; + } + } } } + loop_i = loop_i.wrapping_add(1); if loop_i % 128 == 0 { std::thread::yield_now(); } } - if !fed_any_frames { - crate::app_deprintln!("[analysis] loudness failed: no decoded frames"); - return None; - } - let Some(ebu) = ebu else { - crate::app_deprintln!("[analysis] loudness failed: no decoder output"); - return None; - }; - let integrated_lufs = match ebu.loudness_global() { - Ok(v) => v, - Err(e) => { - crate::app_deprintln!("[analysis] loudness_global failed: {}", e); + let bins = normalize_peak_bins(&bin_max); + + let loudness = if let Some(target_lufs) = loudness_target_lufs { + if !fed_any_frames { + crate::app_deprintln!("[analysis] loudness failed: no decoded frames"); return None; } - }; - if !integrated_lufs.is_finite() { - crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite"); - return None; - } - let mut true_peak = 0.0_f64; - let mut true_peak_ok = true; - for ch in 0..ebu_channels { - match ebu.true_peak(ch) { - Ok(v) if v.is_finite() && v > true_peak => true_peak = v, - Ok(_) => {} + let Some(ebu) = ebu else { + crate::app_deprintln!("[analysis] loudness failed: ebu not initialized"); + return None; + }; + let integrated_lufs = match ebu.loudness_global() { + Ok(v) => v, Err(e) => { - true_peak_ok = false; - crate::app_deprintln!("[analysis] true_peak unavailable: {}", e); - break; + crate::app_deprintln!("[analysis] loudness_global failed: {}", e); + return None; + } + }; + if !integrated_lufs.is_finite() { + crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite"); + return None; + } + let mut true_peak = 0.0_f64; + let mut true_peak_ok = true; + for ch in 0..ebu_channels { + match ebu.true_peak(ch) { + Ok(v) if v.is_finite() && v > true_peak => true_peak = v, + Ok(_) => {} + Err(e) => { + true_peak_ok = false; + crate::app_deprintln!("[analysis] true_peak unavailable: {}", e); + break; + } } } - } - if !true_peak_ok { - // Fallback to sample peak if true-peak is not available for this stream/codec. - true_peak = sample_peak_abs; - } + if !true_peak_ok { + true_peak = sample_peak_abs; + } + let recommended_gain_db = + recommended_gain_for_target(integrated_lufs, true_peak, target_lufs); + Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) + } else { + None + }; - let recommended_gain_db = recommended_gain_for_target(integrated_lufs, true_peak, target_lufs); - Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) + Some(PcmScanResult { bins, loudness }) } fn analysis_db_path(app: &tauri::AppHandle) -> Result { diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index dacf8c47..3f85cbd7 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1215,6 +1215,7 @@ async fn ranged_download_task( let dl_started = Instant::now(); let mut next_progress_mb: usize = 1; let mut last_partial_emit = Instant::now(); + let mut last_partial_emit_downloaded: usize = 0; let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5); 'outer: loop { @@ -1292,10 +1293,14 @@ async fn ranged_download_task( downloaded_to.store(downloaded, Ordering::SeqCst); if downloaded >= 4096 && total_size > 0 + && downloaded.saturating_sub(last_partial_emit_downloaded) >= PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA && last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded) { - let bins = - derive_partial_waveform_bins_short_locks(&buf, downloaded, 500); + // Keep streaming path lightweight: avoid expensive PCM decode + // in the hot loop (it can steal CPU and cause audible underruns + // while playback is starting). We emit a fast sampled waveform + // here and let full analysis/backfill produce final bins later. + let bins = derive_partial_waveform_bins_short_locks(&buf, downloaded, 500); if last_partial_loudness_emit.elapsed() >= Duration::from_millis(PARTIAL_LOUDNESS_EMIT_INTERVAL_MS) { let target_lufs = f32::from_bits(normalization_target_lufs.load(Ordering::Relaxed)); if let Some(provisional_db) = provisional_loudness_gain_from_progress(downloaded, total_size, target_lufs) { @@ -1334,6 +1339,7 @@ async fn ranged_download_task( }, ); last_partial_emit = Instant::now(); + last_partial_emit_downloaded = downloaded; } let mb = downloaded / (1024 * 1024); if mb >= next_progress_mb { @@ -1386,11 +1392,11 @@ async fn ranged_download_task( fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration { const MB: usize = 1024 * 1024; if downloaded <= 3 * MB { - Duration::from_millis(280) + Duration::from_millis(260) } else if downloaded <= 10 * MB { - Duration::from_millis(650) + Duration::from_millis(620) } else { - Duration::from_millis(1100) + Duration::from_millis(980) } } @@ -2785,7 +2791,8 @@ async fn fetch_data( /// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping. const MASTER_HEADROOM: f32 = 0.891_254; const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024; -const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 350; +const PARTIAL_LOUDNESS_EMIT_INTERVAL_MS: u64 = 900; +const PARTIAL_WAVEFORM_EMIT_MIN_BYTES_DELTA: usize = 192 * 1024; /// Until integrated LUFS is known, stay clearly below "full" level so a follow-up /// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor. const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0; @@ -3688,6 +3695,22 @@ fn spawn_progress_task( gapless_switch_at: Arc, current_playback_url: Arc>>, ) { + fn estimated_output_latency_secs(sample_rate_hz: f64) -> f64 { + #[cfg(target_os = "linux")] + { + // Keep progress aligned with audible output (ALSA/PipeWire/Pulse + // queue). We mirror the quantum policy used for stream open/reopen. + let rate = sample_rate_hz.max(1.0); + let frames = if rate > 48_000.0 { 8192.0 } else { 4096.0 }; + // Add a small scheduler/mixer cushion so UI doesn't run ahead. + return (frames / rate) + 0.012; + } + #[cfg(not(target_os = "linux"))] + { + 0.0 + } + } + tokio::spawn(async move { let mut near_end_ticks: u32 = 0; // Local done-flag reference; swapped on gapless transition. @@ -3775,21 +3798,25 @@ fn spawn_progress_task( let samples = samples_played.load(Ordering::Relaxed) as f64; let divisor = (rate * ch).max(1.0); - let dur = { + // Read playback snapshot under a single lock to minimize contention + // with seek/play/pause commands that also touch `current`. + let (dur, paused_at) = { let cur = current_arc.lock().unwrap(); - cur.duration_secs - }; - let is_paused = { - let cur = current_arc.lock().unwrap(); - cur.paused_at.is_some() + (cur.duration_secs, cur.paused_at) }; + let is_paused = paused_at.is_some(); - let pos = if is_paused { - let cur = current_arc.lock().unwrap(); - cur.paused_at.unwrap_or(0.0) + let pos_raw = if let Some(p) = paused_at { + p } else { (samples / divisor).min(dur.max(0.001)) }; + let progress_latency = if is_paused { + 0.0 + } else { + estimated_output_latency_secs(rate) + }; + let pos = (pos_raw - progress_latency).max(0.0); app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok(); @@ -3801,7 +3828,7 @@ fn spawn_progress_task( let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64; let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 }; - if dur > end_threshold && pos >= dur - end_threshold { + if dur > end_threshold && pos_raw >= dur - end_threshold { near_end_ticks += 1; // At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms. if near_end_ticks >= 10 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f99eda29..4fdef949 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1258,6 +1258,13 @@ fn analysis_delete_loudness_for_track( cache.delete_loudness_for_track_id(&track_id) } +#[tauri::command] +fn analysis_delete_all_waveforms( + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + cache.delete_all_waveforms() +} + #[tauri::command] fn analysis_enqueue_seed_from_url( track_id: String, @@ -4108,6 +4115,7 @@ pub fn run() { analysis_get_waveform_for_track, analysis_get_loudness_for_track, analysis_delete_loudness_for_track, + analysis_delete_all_waveforms, analysis_enqueue_seed_from_url, download_track_offline, delete_offline_track, diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 7781c6d1..070a61a3 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -8,6 +8,8 @@ function fmt(s: number): string { const BAR_COUNT = 500; const SEG_COUNT = 60; +const FLAT_WAVE_NORM = 0.06; +const WAVE_MORPH_MS = 1000; // ── animation state ─────────────────────────────────────────────────────────── @@ -96,6 +98,22 @@ export function makeHeights(trackId: string): Float32Array { // ── draw functions ──────────────────────────────────────────────────────────── +function makeFlatWaveHeights(): Float32Array { + const h = new Float32Array(BAR_COUNT); + h.fill(FLAT_WAVE_NORM); + return h; +} + +function easeOutCubic(t: number): number { + const x = Math.max(0, Math.min(1, t)); + return 1 - Math.pow(1 - x, 3); +} + +function waveformBarThickness(logicalH: number, norm: number): number { + const safeNorm = Math.max(FLAT_WAVE_NORM, norm); + return Math.max(1, safeNorm * logicalH); +} + function drawWaveform( canvas: HTMLCanvasElement, heights: Float32Array | null, @@ -108,9 +126,38 @@ function drawWaveform( const { played, buffered: buffCol, unplayed } = getColors(); if (!heights) { - ctx.globalAlpha = 0.3; + // No waveform data yet: flat rail like `drawLineDot`, but do not return early + // before played/buffered — otherwise there is no visible playhead. + const cy = h / 2; + const lh = 2; + const dotR = 5; + ctx.globalAlpha = 0.35; ctx.fillStyle = unplayed; - ctx.fillRect(0, (h - 2) / 2, w, 2); + ctx.fillRect(0, cy - lh / 2, w, lh); + if (buffered > 0) { + ctx.globalAlpha = 0.55; + ctx.fillStyle = buffCol; + ctx.fillRect(0, cy - lh / 2, Math.min(1, buffered) * w, lh); + } + if (progress > 0) { + ctx.globalAlpha = 1; + ctx.fillStyle = played; + ctx.shadowColor = played; + ctx.shadowBlur = 5; + ctx.fillRect(0, cy - lh / 2, Math.min(1, progress) * w, lh); + ctx.shadowBlur = 0; + } + ctx.globalAlpha = 1; + if (w > 0) { + const dx = Math.max(dotR, Math.min(w - dotR, Math.min(1, progress) * w)); + ctx.shadowColor = played; + ctx.shadowBlur = 7; + ctx.beginPath(); + ctx.arc(dx, cy, dotR, 0, Math.PI * 2); + ctx.fillStyle = played; + ctx.fill(); + ctx.shadowBlur = 0; + } ctx.globalAlpha = 1; return; } @@ -122,7 +169,7 @@ function drawWaveform( ctx.fillStyle = unplayed; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT < buffered) continue; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } @@ -132,7 +179,7 @@ function drawWaveform( for (let i = 0; i < BAR_COUNT; i++) { const frac = i / BAR_COUNT; if (frac < progress || frac >= buffered) continue; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } @@ -140,29 +187,14 @@ function drawWaveform( if (progress > 0) { ctx.globalAlpha = 1; ctx.fillStyle = played; - ctx.shadowColor = played; - ctx.shadowBlur = 5; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT >= progress) break; - const bh = Math.max(1, heights[i] * h); + const bh = waveformBarThickness(h, heights[i]); const x = x1Of(i); ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh); } - ctx.shadowBlur = 0; } ctx.globalAlpha = 1; - - // Fade both edges to transparent using destination-in gradient mask - const fadeW = Math.min(22, w * 0.07); - const mask = ctx.createLinearGradient(0, 0, w, 0); - mask.addColorStop(0, 'transparent'); - mask.addColorStop(fadeW / w, 'black'); - mask.addColorStop(1 - fadeW / w, 'black'); - mask.addColorStop(1, 'transparent'); - ctx.globalCompositeOperation = 'destination-in'; - ctx.fillStyle = mask; - ctx.fillRect(0, 0, w, h); - ctx.globalCompositeOperation = 'source-over'; } function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) { @@ -824,7 +856,6 @@ export default function WaveformSeek({ trackId }: Props) { const SEEK_COMMIT_PROGRESS_EPS = 0.02; const canvasRef = useRef(null); const heightsRef = useRef(null); - const lastHeightsTrackIdRef = useRef(undefined); const progressRef = useRef(usePlayerStore.getState().progress); const bufferedRef = useRef(usePlayerStore.getState().buffered); const isDragging = useRef(false); @@ -848,43 +879,73 @@ export default function WaveformSeek({ trackId }: Props) { useEffect(() => { if (!trackId) { heightsRef.current = null; - lastHeightsTrackIdRef.current = undefined; return; } if (waveformBins && waveformBins.length > 0) { const src = waveformBins; const h = new Float32Array(BAR_COUNT); - const effectiveDur = waveformDurationSec > 0 ? waveformDurationSec : duration; - const knownFrac = waveformIsPartial && effectiveDur > 0 - ? Math.max(0, Math.min(1, waveformKnownUntilSec / effectiveDur)) - : 1; - const knownBars = Math.max(0, Math.min(BAR_COUNT, Math.floor(knownFrac * BAR_COUNT))); for (let i = 0; i < BAR_COUNT; i++) { - if (i >= knownBars) { - h[i] = 0.08; // unknown tail baseline while partial waveform is still loading - continue; - } const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length)); const v = src[idx]; h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); } - // Partial -> full handoff: blend with previous heights for a smoother - // visual transition and avoid a hard "jump" in the waveform shape. - if (lastHeightsTrackIdRef.current === trackId && heightsRef.current && heightsRef.current.length === BAR_COUNT) { - const prev = heightsRef.current; - const mixed = new Float32Array(BAR_COUNT); - for (let i = 0; i < BAR_COUNT; i++) { - mixed[i] = prev[i] * 0.45 + h[i] * 0.55; - } - heightsRef.current = mixed; - } else { + const prev = heightsRef.current; + if (!prev || prev.length !== BAR_COUNT) { heightsRef.current = h; + return; } - lastHeightsTrackIdRef.current = trackId; - return; + const from = new Float32Array(prev); + const to = h; + const startedAt = performance.now(); + let raf = 0; + const step = (now: number) => { + const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS); + const next = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + next[i] = from[i] + (to[i] - from[i]) * p; + } + heightsRef.current = next; + if (!ANIMATED_STYLES.has(styleRef.current)) { + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current); + } + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); } - heightsRef.current = makeHeights(trackId); - lastHeightsTrackIdRef.current = trackId; + if (heightsRef.current?.length === BAR_COUNT) { + const current = heightsRef.current; + let isAlreadyFlat = true; + for (let i = 0; i < BAR_COUNT; i++) { + if (Math.abs(current[i] - FLAT_WAVE_NORM) > 0.0001) { + isAlreadyFlat = false; + break; + } + } + if (isAlreadyFlat) return; + const from = new Float32Array(current); + const to = makeFlatWaveHeights(); + const startedAt = performance.now(); + let raf = 0; + const step = (now: number) => { + const p = easeOutCubic((now - startedAt) / WAVE_MORPH_MS); + const next = new Float32Array(BAR_COUNT); + for (let i = 0; i < BAR_COUNT; i++) { + next[i] = from[i] + (to[i] - from[i]) * p; + } + heightsRef.current = next; + if (!ANIMATED_STYLES.has(styleRef.current)) { + const canvas = canvasRef.current; + if (canvas) drawSeekbar(canvas, styleRef.current, next, progressRef.current, bufferedRef.current, animStateRef.current); + } + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + return () => cancelAnimationFrame(raf); + } + // No analysis bins yet: render 500 flat bars immediately. + heightsRef.current = makeFlatWaveHeights(); }, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]); // Imperative subscription — no React re-renders from progress changes. @@ -913,12 +974,20 @@ export default function WaveformSeek({ trackId }: Props) { }); }, []); - // Initial draw for static styles when style or track changes. + // Initial draw for static styles when style, track, or waveform payload changes. useEffect(() => { if (ANIMATED_STYLES.has(seekbarStyle)) return; const canvas = canvasRef.current; if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current); - }, [seekbarStyle, trackId]); + }, [ + seekbarStyle, + trackId, + waveformBins, + waveformIsPartial, + waveformKnownUntilSec, + waveformDurationSec, + duration, + ]); // rAF loop — animated styles only. useEffect(() => { diff --git a/src/locales/en.ts b/src/locales/en.ts index bf61a1f1..a95ceba7 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -656,9 +656,12 @@ export const enTranslation = { hotCacheTrackCount: 'Tracks in cache:', cacheMaxLabel: 'Max. size', cacheClearBtn: 'Clear Cache', + waveformCacheClearBtn: 'Clear waveform cache', cacheClearWarning: 'This will also remove all offline albums from the library.', cacheClearConfirm: 'Clear Everything', cacheClearCancel: 'Cancel', + waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).', + waveformCacheClearFailed: 'Failed to clear waveform cache.', offlineDirTitle: 'Offline Library (In-App)', offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.', offlineDirDefault: 'Default (App Data)', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 22a1e60a..555b193f 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -677,9 +677,12 @@ export const ruTranslation = { hotCacheTrackCount: 'Треков в кэше:', cacheMaxLabel: 'Лимит', cacheClearBtn: 'Очистить кэш', + waveformCacheClearBtn: 'Очистить кэш waveform', cacheClearWarning: 'Будут удалены и все офлайн-альбомы.', cacheClearConfirm: 'Очистить всё', cacheClearCancel: 'Отмена', + waveformCacheCleared: 'Кэш waveform очищен ({{count}} строк).', + waveformCacheClearFailed: 'Не удалось очистить кэш waveform.', offlineDirTitle: 'Офлайн-библиотека (в приложении)', offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.', offlineDirDefault: 'По умолчанию (данные приложения)', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index a9d38142..28f9cd98 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -17,6 +17,7 @@ import { open as openUrl } from '@tauri-apps/plugin-shell'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { useOfflineStore } from '../store/offlineStore'; import { useHotCacheStore } from '../store/hotCacheStore'; +import { usePlayerStore } from '../store/playerStore'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; @@ -1788,6 +1789,29 @@ export default function Settings() { setClearing(false); }, [clearAllOffline, serverId]); + const handleClearWaveformCache = useCallback(async () => { + setClearing(true); + try { + const deleted = await invoke('analysis_delete_all_waveforms'); + usePlayerStore.setState({ + waveformBins: null, + waveformIsPartial: false, + waveformKnownUntilSec: 0, + waveformDurationSec: 0, + }); + showToast( + t('settings.waveformCacheCleared', { count: deleted }), + 3500, + 'success', + ); + } catch (e) { + console.error(e); + showToast(t('settings.waveformCacheClearFailed'), 4500, 'error'); + } finally { + setClearing(false); + } + }, [t]); + const startLastfmConnect = useCallback(async () => { setLfmError(null); let token: string; @@ -3032,6 +3056,16 @@ export default function Settings() { {t('settings.cacheClearBtn')} )} +
+ +
diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 68ddf437..e2210483 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -824,19 +824,11 @@ function handleAudioProgress(current_time: number, duration: number) { const dur = duration > 0 ? duration : track.duration; if (dur <= 0) return; const progress = displayTime / dur; - const stateNow = usePlayerStore.getState(); - if (stateNow.waveformIsPartial) { - usePlayerStore.setState({ - currentTime: displayTime, - progress, - buffered: 0, - // Keep known span aligned with playback position during partial stage: - // if playback moved forward, this part is definitely known already. - waveformKnownUntilSec: Math.max(stateNow.waveformKnownUntilSec || 0, displayTime), - }); - } else { - usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); - } + // Do not couple `waveformKnownUntilSec` to playhead: for partial streams it + // must track **download/analysis progress** (emitted in `analysis:waveform-partial`). + // Using `displayTime` here broke the seekbar after seeks — bins only cover the + // buffered prefix of the file while `knownUntil` jumped with the seek position. + usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); // Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played) if (progress >= 0.5 && !store.scrobbled) { @@ -1123,11 +1115,37 @@ export function initAudioListeners(): () => void { usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 }); return; } - usePlayerStore.setState({ - waveformBins: payload.bins, - waveformIsPartial: true, - waveformKnownUntilSec: Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0, - waveformDurationSec: Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0), + const nextKnownUntilSec = Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0; + const nextDurationSec = Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0); + usePlayerStore.setState((prev) => { + const prevBins = prev.waveformBins; + const prevKnownUntilSec = Number.isFinite(prev.waveformKnownUntilSec) ? prev.waveformKnownUntilSec : 0; + if (!prevBins || prevBins.length === 0 || prevKnownUntilSec <= 0 || nextDurationSec <= 0) { + return { + waveformBins: payload.bins, + waveformIsPartial: true, + waveformKnownUntilSec: nextKnownUntilSec, + waveformDurationSec: nextDurationSec, + }; + } + const len = Math.max(prevBins.length, payload.bins.length); + const merged = new Array(len); + for (let i = 0; i < len; i++) { + merged[i] = i < prevBins.length ? prevBins[i] : 0; + } + const prevKnownBars = Math.max(0, Math.min(len, Math.floor((prevKnownUntilSec / nextDurationSec) * len))); + const nextKnownBars = Math.max(0, Math.min(len, Math.floor((nextKnownUntilSec / nextDurationSec) * len))); + if (nextKnownBars > prevKnownBars) { + for (let i = prevKnownBars; i < nextKnownBars; i++) { + if (i < payload.bins.length) merged[i] = payload.bins[i]; + } + } + return { + waveformBins: merged, + waveformIsPartial: true, + waveformKnownUntilSec: Math.max(prevKnownUntilSec, nextKnownUntilSec), + waveformDurationSec: nextDurationSec, + }; }); }), listen<{ trackId?: string | null; gainDb: number; targetLufs: number; isPartial: boolean }>('analysis:loudness-partial', ({ payload }) => { @@ -1151,6 +1169,9 @@ export function initAudioListeners(): () => void { if (!payloadTrackId) return; const currentId = usePlayerStore.getState().currentTrack?.id; if (currentId && payloadTrackId === currentId) { + if (!payload.isPartial) { + usePlayerStore.setState({ waveformIsPartial: false }); + } void refreshWaveformForTrack(currentId); void refreshLoudnessForTrack(currentId); emitNormalizationDebug('backfill:applied', { trackId: currentId });