diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 352eb8e4..6535097c 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -2822,161 +2822,3 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { state.gapless_enabled.store(enabled, Ordering::Relaxed); } - -// ─── Waveform analysis ──────────────────────────────────────────────────────── - -/// Download `url`, decode with Symphonia and return `bars` normalised peak -/// amplitudes in [0, 1]. Runs the CPU-bound decode on the blocking thread -/// pool so the async runtime is not starved. -#[tauri::command] -pub async fn compute_waveform( - url: String, - state: State<'_, AudioEngine>, -) -> Result, String> { - const BARS: usize = 500; - - // Fetch bytes — prefer local offline file, fall back to HTTP. - let data: Vec = if let Some(path) = url.strip_prefix("psysonic-local://") { - tokio::fs::read(path).await.map_err(|e| e.to_string())? - } else { - let resp = state - .http_client - .get(&url) - .send() - .await - .map_err(|e| e.to_string())?; - if !resp.status().is_success() { - return Err(format!("HTTP {}", resp.status().as_u16())); - } - resp.bytes().await.map_err(|e| e.to_string())?.to_vec() - }; - - tokio::task::spawn_blocking(move || decode_peaks(data, BARS)) - .await - .map_err(|e| e.to_string())? -} - -fn decode_peaks(data: Vec, bars: usize) -> Result, String> { - use symphonia::core::audio::SampleBuffer as SB; - - let data_len = data.len() as u64; - let source = SizedCursorSource { inner: Cursor::new(data), len: data_len }; - let mss = MediaSourceStream::new( - Box::new(source) as Box, - MediaSourceStreamOptions { buffer_len: 512 * 1024 }, - ); - - let format_opts = FormatOptions { enable_gapless: false, ..Default::default() }; - let meta_opts = symphonia::core::meta::MetadataOptions { - limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024), - ..Default::default() - }; - - let mut format = symphonia::default::get_probe() - .format(&Hint::new(), mss, &format_opts, &meta_opts) - .map_err(|e| e.to_string())? - .format; - - let track = format - .tracks() - .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL && t.codec_params.sample_rate.is_some()) - .ok_or("no audio track")?; - - let track_id = track.id; - let mut decoder = symphonia::default::get_codecs() - .make(&track.codec_params, &DecoderOptions::default()) - .map_err(|e| e.to_string())?; - - // Determine total duration so we can seek to evenly-spaced positions - // instead of decoding every frame sequentially. - let duration_secs: Option = track.codec_params.time_base - .zip(track.codec_params.n_frames) - .map(|(tb, nf)| { - let t = tb.calc_time(nf); - t.seconds as f64 + t.frac - }); - - let mut pooled: Vec = Vec::with_capacity(bars); - let mut sbuf: Option> = None; - - if let Some(dur) = duration_secs.filter(|&d| d > 0.1) { - // ── Fast path: seek to each bar's timestamp, decode one frame ───────── - // Reduces decode work from ~thousands of frames to exactly `bars` frames. - for bar in 0..bars { - let target = (bar as f64 / bars as f64) * dur; - let seek_time = symphonia::core::units::Time { - seconds: target as u64, - frac: target.fract(), - }; - let _ = format.seek( - SeekMode::Coarse, - SeekTo::Time { time: seek_time, track_id: Some(track_id) }, - ); - decoder.reset(); - - // Try up to 16 packets to find a decodable audio packet near the - // seek position (some packets may be non-audio or malformed). - let peak = 'packet: { - for _ in 0..16 { - match format.next_packet() { - Ok(pkt) if pkt.track_id() == track_id => { - if let Ok(decoded) = decoder.decode(&pkt) { - let buf = sbuf.get_or_insert_with(|| { - SB::::new(decoded.capacity() as u64, *decoded.spec()) - }); - buf.copy_interleaved_ref(decoded); - break 'packet buf.samples().iter().map(|s| s.abs()).fold(0.0f32, f32::max); - } - } - Ok(_) => continue, - Err(_) => break 'packet 0.0, - } - } - 0.0 - }; - pooled.push(peak); - } - } else { - // ── Slow path: sequential decode, then downsample ───────────────────── - // Used when the format doesn't report n_frames (rare edge case). - let mut frame_peaks: Vec = Vec::with_capacity(8192); - loop { - let packet = match format.next_packet() { - Ok(p) => p, - Err(symphonia::core::errors::Error::ResetRequired) => { let _ = decoder.reset(); continue; } - Err(_) => break, - }; - if packet.track_id() != track_id { continue; } - let decoded = match decoder.decode(&packet) { Ok(d) => d, Err(_) => continue }; - let buf = sbuf.get_or_insert_with(|| { - SB::::new(decoded.capacity() as u64, *decoded.spec()) - }); - buf.copy_interleaved_ref(decoded); - frame_peaks.push(buf.samples().iter().map(|s| s.abs()).fold(0.0f32, f32::max)); - } - if frame_peaks.is_empty() { - return Ok(vec![0.5f32; bars]); - } - let n = frame_peaks.len(); - pooled = (0..bars).map(|i| { - let start = (i * n) / bars; - let end = (((i + 1) * n) / bars).max(start + 1).min(n); - frame_peaks[start..end].iter().cloned().fold(0.0f32, f32::max) - }).collect(); - } - - // Percentile-based normalisation: always spreads the full value range - // across the visual height, which preserves relative dynamics even for - // heavily compressed ("loudness war") music where all peaks cluster near 1.0. - let mut sorted = pooled.clone(); - sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let nb = sorted.len(); - let p5 = sorted[(nb * 5 / 100).max(0)]; - let p95 = sorted[(nb * 95 / 100).min(nb - 1)]; - let range = (p95 - p5).max(1e-6); - - Ok(pooled.iter() - .map(|&v| 0.12 + 0.88 * ((v - p5) / range).clamp(0.0, 1.0)) - .collect()) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 980d0d56..24a78387 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -994,7 +994,6 @@ pub fn run() { audio::audio_play_radio, audio::audio_set_crossfade, audio::audio_set_gapless, - audio::compute_waveform, audio::audio_chain_preload, discord::discord_update_presence, discord::discord_clear_presence, diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index ab61be90..a92a0a56 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -7,7 +7,6 @@ import { import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; -import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl'; import CachedImage from './CachedImage'; import WaveformSeek from './WaveformSeek'; import Equalizer from './Equalizer'; @@ -40,7 +39,6 @@ export default function PlayerBar() { starredOverrides, setStarredOverride, } = usePlayerStore(); const { lastfmSessionKey } = useAuthStore(); - const serverId = useAuthStore(s => s.activeServerId ?? ''); const isRadio = !!currentRadio; @@ -209,10 +207,7 @@ export default function PlayerBar() { <> {formatTime(currentTime)}
- +
{formatTime(duration)} diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 9e033463..56ca76ce 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../store/playerStore'; function fmt(s: number): string { @@ -9,8 +8,6 @@ function fmt(s: number): string { const BAR_COUNT = 500; -// ── Pseudo-random fallback (shown while real waveform loads) ────────────────── - function hashStr(str: string): number { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { @@ -20,18 +17,20 @@ function hashStr(str: string): number { return h; } -function makeFallbackHeights(trackId: string): Float32Array { +function makeHeights(trackId: string): Float32Array { let s = hashStr(trackId); const h = new Float32Array(BAR_COUNT); for (let i = 0; i < BAR_COUNT; i++) { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; h[i] = s / 0xffffffff; } + // Smooth for an organic look for (let pass = 0; pass < 5; pass++) { for (let i = 1; i < BAR_COUNT - 1; i++) { h[i] = h[i - 1] * 0.25 + h[i] * 0.5 + h[i + 1] * 0.25; } } + // Normalize to [0.12, 1.0] let max = 0; for (let i = 0; i < BAR_COUNT; i++) if (h[i] > max) max = h[i]; if (max > 0) { @@ -40,11 +39,6 @@ function makeFallbackHeights(trackId: string): Float32Array { return h; } -// ── Real waveform cache (module-level, persists across re-renders) ──────────── -const waveformCache = new Map(); - -// ── Canvas drawing ──────────────────────────────────────────────────────────── - function drawWaveform( canvas: HTMLCanvasElement, heights: Float32Array | null, @@ -82,10 +76,11 @@ function drawWaveform( return; } + // Use fractional x positions so adjacent bars share exact pixel boundaries — no gaps. const x1Of = (i: number) => (i / BAR_COUNT) * w; const x2Of = (i: number) => ((i + 1) / BAR_COUNT) * w; - // Pass 1 — unplayed + // Pass 1 — unplayed (dim) ctx.globalAlpha = 0.28; ctx.fillStyle = colorUnplayed; for (let i = 0; i < BAR_COUNT; i++) { @@ -95,7 +90,7 @@ function drawWaveform( ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); } - // Pass 2 — buffered + // Pass 2 — buffered (slightly brighter) ctx.globalAlpha = 0.45; ctx.fillStyle = colorBuffered; for (let i = 0; i < BAR_COUNT; i++) { @@ -106,7 +101,7 @@ function drawWaveform( ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); } - // Pass 3 — played + // Pass 3 — played (accent color + glow) if (progress > 0) { ctx.globalAlpha = 1; ctx.fillStyle = colorAccent; @@ -124,27 +119,16 @@ function drawWaveform( ctx.globalAlpha = 1; } -const CROSSFADE_MS = 400; - -function easeInOut(t: number): number { - return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; -} - -// ── Component ───────────────────────────────────────────────────────────────── - interface Props { trackId: string | undefined; - url: string | undefined; } -export default function WaveformSeek({ trackId, url }: Props) { - const canvasRef = useRef(null); - const heightsRef = useRef(null); - const progressRef = useRef(0); - const bufferedRef = useRef(0); - const isDragging = useRef(false); - const computingId = useRef(null); - const rafRef = useRef(null); +export default function WaveformSeek({ trackId }: Props) { + const canvasRef = useRef(null); + const heightsRef = useRef(null); + const progressRef = useRef(0); + const bufferedRef = useRef(0); + const isDragging = useRef(false); const [hoverPct, setHoverPct] = useState(null); @@ -156,64 +140,9 @@ export default function WaveformSeek({ trackId, url }: Props) { progressRef.current = progress; bufferedRef.current = buffered; - const startCrossfade = (from: Float32Array, to: Float32Array) => { - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); - const start = performance.now(); - const blended = new Float32Array(BAR_COUNT); - - const step = (now: number) => { - const t = easeInOut(Math.min((now - start) / CROSSFADE_MS, 1)); - for (let i = 0; i < BAR_COUNT; i++) blended[i] = from[i] * (1 - t) + to[i] * t; - heightsRef.current = blended; - if (canvasRef.current) drawWaveform(canvasRef.current, blended, progressRef.current, bufferedRef.current); - if (t < 1) { - rafRef.current = requestAnimationFrame(step); - } else { - heightsRef.current = to; - rafRef.current = null; - } - }; - rafRef.current = requestAnimationFrame(step); - }; - - // When track changes: show fallback immediately, then compute real waveform useEffect(() => { - if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } - - if (!trackId || !url) { - heightsRef.current = null; - computingId.current = null; - return; - } - - // Check cache first — no transition needed, just draw immediately - const cached = waveformCache.get(trackId); - if (cached) { - heightsRef.current = cached; - if (canvasRef.current) drawWaveform(canvasRef.current, cached, progressRef.current, bufferedRef.current); - return; - } - - // Show pseudo-random while loading - const fallback = makeFallbackHeights(trackId); - heightsRef.current = fallback; - if (canvasRef.current) drawWaveform(canvasRef.current, fallback, progressRef.current, bufferedRef.current); - - // Kick off background computation - const id = trackId; - computingId.current = id; - - invoke('compute_waveform', { url }) - .then(values => { - if (computingId.current !== id) return; // track changed while computing - const real = new Float32Array(values); - waveformCache.set(id, real); - // Crossfade from whatever is currently showing to the real waveform - const current = heightsRef.current ?? fallback; - startCrossfade(current, real); - }) - .catch(() => { /* keep pseudo-random on error */ }); - }, [trackId, url]); + heightsRef.current = trackId ? makeHeights(trackId) : null; + }, [trackId]); useEffect(() => { if (canvasRef.current) { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 51c88065..d6fc9bad 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1161,7 +1161,7 @@ export default function Settings() {
-
@@ -1247,7 +1247,7 @@ export default function Settings() {

-