From 504c53e71df4eed7c357ad1c2a13ee31ef007db1 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 23:31:15 +0200 Subject: [PATCH] feat(waveform): real amplitude waveform via Symphonia + smooth crossfade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Rust command `compute_waveform` that downloads the audio file, seek-samples 500 evenly-spaced frames with Symphonia (fast path) and computes peak amplitudes. Percentile-based normalisation (p5→p95) preserves visible dynamics even for heavily compressed music. Frontend caches results per track (module-level Map), shows the pseudo-random fallback immediately and crossfades to the real waveform over 400 ms (ease-in-out) once the computation finishes. Also: Settings input-tab reset buttons now use btn-danger styling. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/audio.rs | 158 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/PlayerBar.tsx | 7 +- src/components/WaveformSeek.tsx | 105 +++++++++++++++++---- src/pages/Settings.tsx | 4 +- 5 files changed, 255 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 6535097c..352eb8e4 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -2822,3 +2822,161 @@ 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 24a78387..980d0d56 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -994,6 +994,7 @@ 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 a92a0a56..ab61be90 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -7,6 +7,7 @@ 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'; @@ -39,6 +40,7 @@ export default function PlayerBar() { starredOverrides, setStarredOverride, } = usePlayerStore(); const { lastfmSessionKey } = useAuthStore(); + const serverId = useAuthStore(s => s.activeServerId ?? ''); const isRadio = !!currentRadio; @@ -207,7 +209,10 @@ export default function PlayerBar() { <> {formatTime(currentTime)}
- +
{formatTime(duration)} diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 56ca76ce..9e033463 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../store/playerStore'; function fmt(s: number): string { @@ -8,6 +9,8 @@ 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++) { @@ -17,20 +20,18 @@ function hashStr(str: string): number { return h; } -function makeHeights(trackId: string): Float32Array { +function makeFallbackHeights(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) { @@ -39,6 +40,11 @@ function makeHeights(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, @@ -76,11 +82,10 @@ 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 (dim) + // Pass 1 — unplayed ctx.globalAlpha = 0.28; ctx.fillStyle = colorUnplayed; for (let i = 0; i < BAR_COUNT; i++) { @@ -90,7 +95,7 @@ function drawWaveform( ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); } - // Pass 2 — buffered (slightly brighter) + // Pass 2 — buffered ctx.globalAlpha = 0.45; ctx.fillStyle = colorBuffered; for (let i = 0; i < BAR_COUNT; i++) { @@ -101,7 +106,7 @@ function drawWaveform( ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); } - // Pass 3 — played (accent color + glow) + // Pass 3 — played if (progress > 0) { ctx.globalAlpha = 1; ctx.fillStyle = colorAccent; @@ -119,16 +124,27 @@ function drawWaveform( ctx.globalAlpha = 1; } -interface Props { - trackId: string | undefined; +const CROSSFADE_MS = 400; + +function easeInOut(t: number): number { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; } -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); +// ── 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); const [hoverPct, setHoverPct] = useState(null); @@ -140,9 +156,64 @@ export default function WaveformSeek({ trackId }: 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(() => { - heightsRef.current = trackId ? makeHeights(trackId) : null; - }, [trackId]); + 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]); useEffect(() => { if (canvasRef.current) { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index d6fc9bad..51c88065 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() {

-