diff --git a/src-tauri/src/analysis_cache.rs b/src-tauri/src/analysis_cache.rs index b31e72c0..582d5ba3 100644 --- a/src-tauri/src/analysis_cache.rs +++ b/src-tauri/src/analysis_cache.rs @@ -14,9 +14,18 @@ use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use tauri::Manager; -pub const WAVEFORM_ALGO_VERSION: i64 = 3; +pub const WAVEFORM_ALGO_VERSION: i64 = 4; pub const LOUDNESS_ALGO_VERSION: i64 = 1; +/// Bins in waveform BLOB: `2 * bin_count` bytes (peak u8, then mean-abs u8 per time bin). +pub fn waveform_cache_blob_len_ok(bins: &[u8], bin_count: i64) -> bool { + if bin_count <= 0 { + return false; + } + let n = bin_count as usize; + bins.len() == n.saturating_mul(2) +} + #[derive(Debug, Clone)] pub struct TrackKey { pub track_id: String, @@ -200,8 +209,9 @@ impl AnalysisCache { pub fn get_waveform(&self, key: &TrackKey) -> Result, String> { let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; - conn.query_row( - r#" + let row = conn + .query_row( + r#" SELECT w.bins, w.bin_count, w.is_partial, w.known_until_sec, w.duration_sec, w.updated_at FROM waveform_cache w JOIN analysis_track a @@ -211,20 +221,21 @@ impl AnalysisCache { AND w.md5_16kb = ?2 AND a.waveform_algo_version = ?3 "#, - params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION], - |row| { - Ok(WaveformEntry { - bins: row.get(0)?, - bin_count: row.get(1)?, - is_partial: row.get::<_, i64>(2)? != 0, - known_until_sec: row.get(3)?, - duration_sec: row.get(4)?, - updated_at: row.get(5)?, - }) - }, - ) - .optional() - .map_err(|e| e.to_string()) + params![key.track_id, key.md5_16kb, WAVEFORM_ALGO_VERSION], + |row| { + Ok(WaveformEntry { + bins: row.get(0)?, + bin_count: row.get(1)?, + is_partial: row.get::<_, i64>(2)? != 0, + known_until_sec: row.get(3)?, + duration_sec: row.get(4)?, + updated_at: row.get(5)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count))) } pub fn get_latest_waveform_for_track(&self, track_id: &str) -> Result, String> { @@ -258,8 +269,10 @@ impl AnalysisCache { ) .optional() .map_err(|e| e.to_string())?; - if row.is_some() { - return Ok(row); + if let Some(e) = row { + if waveform_cache_blob_len_ok(&e.bins, e.bin_count) { + return Ok(Some(e)); + } } } Ok(None) @@ -456,8 +469,8 @@ fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec { if bin_count == 0 || bytes.is_empty() { return Vec::new(); } - let mut out = vec![0u8; bin_count]; - for (i, slot) in out.iter_mut().enumerate() { + let mut peak_half = vec![0u8; bin_count]; + for (i, slot) in peak_half.iter_mut().enumerate() { let start = i * bytes.len() / bin_count; let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len()); let mut peak: u8 = 0; @@ -469,6 +482,8 @@ fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec { } *slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8; } + let mut out = peak_half.clone(); + out.extend_from_slice(&peak_half); out } @@ -620,6 +635,8 @@ fn decode_scan_pcm( }; let mut bin_max = vec![0.0f32; bin_count]; + let mut bin_sum = vec![0.0f32; bin_count]; + let mut bin_n = vec![0u32; bin_count]; let mut ebu: Option = None; let mut ebu_channels: u32 = 0; let mut sample_peak_abs = 0.0_f64; @@ -695,6 +712,8 @@ fn decode_scan_pcm( 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); + bin_sum[bin] += mag; + bin_n[bin] = bin_n[bin].saturating_add(1); } for c in 0..n_ch { let v = (slice[base + c] as f64).abs(); @@ -723,7 +742,17 @@ fn decode_scan_pcm( } } - let bins = normalize_peak_bins(&bin_max); + let mut bin_mean = vec![0.0f32; bin_count]; + for i in 0..bin_count { + if bin_n[i] > 0 { + bin_mean[i] = bin_sum[i] / (bin_n[i] as f32); + } + } + let peak_u8 = normalize_peak_bins(&bin_max); + let mean_u8 = normalize_peak_bins(&bin_mean); + let mut bins = Vec::with_capacity(peak_u8.len().saturating_mul(2)); + bins.extend_from_slice(&peak_u8); + bins.extend_from_slice(&mean_u8); let loudness = if let Some(target_lufs) = loudness_target_lufs { if !fed_any_frames { diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index e3d9636a..f1df3143 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -7,6 +7,11 @@ function fmt(s: number): string { } const BAR_COUNT = 500; +/** Stored waveform bins per track (matches backend `bin_count` / PCM bins). */ +const WAVE_BIN_COUNT = 500; +/** `0.7 * mean + 0.3 * max` in normalized 0..1 space (v4 cache: first half = peak, second = mean-abs). */ +const WAVE_MIX_MEAN = 0.7; +const WAVE_MIX_MAX = 0.3; const SEG_COUNT = 60; const FLAT_WAVE_NORM = 0.06; const WAVE_MORPH_MS = 1000; @@ -111,8 +116,27 @@ function easeOutCubic(t: number): number { function binsToHeights(src: number[]): Float32Array { const h = new Float32Array(BAR_COUNT); + const n = src.length; + if (n === WAVE_BIN_COUNT * 2) { + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT)); + const maxNorm = Number(src[idx]) / 255; + const meanNorm = Number(src[WAVE_BIN_COUNT + idx]) / 255; + const v = WAVE_MIX_MEAN * meanNorm + WAVE_MIX_MAX * maxNorm; + h[i] = Math.max(0.08, Math.min(1, v)); + } + return h; + } + if (n === WAVE_BIN_COUNT) { + for (let i = 0; i < BAR_COUNT; i++) { + const idx = Math.min(WAVE_BIN_COUNT - 1, Math.floor((i / BAR_COUNT) * WAVE_BIN_COUNT)); + const v = src[idx]; + h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); + } + return h; + } for (let i = 0; i < BAR_COUNT; i++) { - const idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length)); + const idx = Math.min(n - 1, Math.floor((i / BAR_COUNT) * n)); const v = src[idx]; h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255))); } diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 5690c606..18244128 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -271,25 +271,34 @@ type WaveformCachePayload = { updatedAt: number; }; +/** v4: `500` peak + `500` mean-abs = `1000` bytes. Legacy single curve: `500` (treated as mean=max). */ +function waveformBlobLenOk(len: number): boolean { + return len === 500 || len === 1000; +} + /** `Vec` from Rust often arrives as `Uint8Array`, not `Array.isArray`. */ function coerceWaveformBins(bins: unknown): number[] | null { if (bins == null) return null; + let raw: number[] | null = null; if (Array.isArray(bins)) { - return bins.length > 0 ? bins.map(x => Number(x) & 255) : null; - } - if (bins instanceof Uint8Array) { - return bins.length > 0 ? Array.from(bins) : null; - } - if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') { + if (bins.length === 0) return null; + raw = bins.map(x => Number(x) & 255); + } else if (bins instanceof Uint8Array) { + if (bins.length === 0) return null; + raw = Array.from(bins); + } else if (typeof bins === 'object' && 'length' in bins && typeof (bins as { length: unknown }).length === 'number') { const len = (bins as { length: number }).length; if (len === 0) return null; try { - return Array.from(bins as ArrayLike).map(x => Number(x) & 255); + raw = Array.from(bins as ArrayLike).map(x => Number(x) & 255); } catch { return null; } + } else { + return null; } - return null; + if (!waveformBlobLenOk(raw.length)) return null; + return raw; } type LoudnessCachePayload = {