mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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.
This commit is contained in:
+219
-30
@@ -14,7 +14,7 @@ use symphonia::core::meta::MetadataOptions;
|
|||||||
use symphonia::core::probe::Hint;
|
use symphonia::core::probe::Hint;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
pub const WAVEFORM_ALGO_VERSION: i64 = 1;
|
pub const WAVEFORM_ALGO_VERSION: i64 = 3;
|
||||||
pub const LOUDNESS_ALGO_VERSION: i64 = 1;
|
pub const LOUDNESS_ALGO_VERSION: i64 = 1;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -102,6 +102,18 @@ impl AnalysisCache {
|
|||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove all cached waveform rows across all tracks/variants.
|
||||||
|
pub fn delete_all_waveforms(&self) -> Result<u64, String> {
|
||||||
|
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> {
|
pub fn touch_track_status(&self, key: &TrackKey, status: &str) -> Result<(), String> {
|
||||||
let now = now_unix_ts();
|
let now = now_unix_ts();
|
||||||
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
|
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")?;
|
cache.touch_track_status(&key, "queued")?;
|
||||||
|
|
||||||
|
let wf_bins: Vec<u8>;
|
||||||
|
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 {
|
let waveform = WaveformEntry {
|
||||||
bins: derive_waveform_bins(bytes, 500),
|
bins: wf_bins,
|
||||||
bin_count: 500,
|
bin_count: 500,
|
||||||
is_partial: false,
|
is_partial: false,
|
||||||
known_until_sec: 0.0,
|
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)?;
|
cache.upsert_waveform(&key, &waveform)?;
|
||||||
|
|
||||||
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) =
|
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt {
|
||||||
analyze_loudness_from_audio_bytes(bytes, -16.0)
|
|
||||||
{
|
|
||||||
let loudness = LoudnessEntry {
|
let loudness = LoudnessEntry {
|
||||||
integrated_lufs,
|
integrated_lufs,
|
||||||
true_peak,
|
true_peak,
|
||||||
@@ -372,11 +394,48 @@ fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(f64, f64, f64, f64)> {
|
struct PcmScanResult {
|
||||||
if bytes.is_empty() {
|
bins: Vec<u8>,
|
||||||
|
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<Vec<u8>> {
|
||||||
|
if bytes.is_empty() || bin_count == 0 {
|
||||||
return None;
|
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<u8>)> {
|
||||||
|
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<u64>)> {
|
||||||
let source = Box::new(Cursor::new(bytes.to_vec()));
|
let source = Box::new(Cursor::new(bytes.to_vec()));
|
||||||
let mss = MediaSourceStream::new(source, Default::default());
|
let mss = MediaSourceStream::new(source, Default::default());
|
||||||
let hint = Hint::new();
|
let hint = Hint::new();
|
||||||
@@ -388,10 +447,98 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(
|
|||||||
.default_track()
|
.default_track()
|
||||||
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
format
|
format.tracks().iter().find(|t| {
|
||||||
.tracks()
|
t.codec_params.codec != CODEC_TYPE_NULL
|
||||||
.iter()
|
&& t.codec_params.sample_rate.is_some()
|
||||||
.find(|t| {
|
&& 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::<f32>::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<u8> {
|
||||||
|
let bin_count = bin_max.len();
|
||||||
|
if bin_count == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut sorted: Vec<f32> = 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<u64>,
|
||||||
|
loudness_target_lufs: Option<f64>,
|
||||||
|
) -> Option<PcmScanResult> {
|
||||||
|
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.codec != CODEC_TYPE_NULL
|
||||||
&& t.codec_params.sample_rate.is_some()
|
&& t.codec_params.sample_rate.is_some()
|
||||||
&& t.codec_params.channels.is_some()
|
&& t.codec_params.channels.is_some()
|
||||||
@@ -400,21 +547,28 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(
|
|||||||
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
|
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
|
||||||
let track_id = track.id;
|
let track_id = track.id;
|
||||||
let codec_params = track.codec_params.clone();
|
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,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
|
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut bin_max = vec![0.0f32; bin_count];
|
||||||
let mut ebu: Option<EbuR128> = None;
|
let mut ebu: Option<EbuR128> = None;
|
||||||
let mut ebu_channels: u32 = 0;
|
let mut ebu_channels: u32 = 0;
|
||||||
let mut sample_peak_abs = 0.0_f64;
|
let mut sample_peak_abs = 0.0_f64;
|
||||||
let mut fed_any_frames = false;
|
let mut fed_any_frames = false;
|
||||||
|
let mut sample_idx: u64 = 0;
|
||||||
let mut loop_i: u32 = 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 {
|
loop {
|
||||||
let packet = match format.next_packet() {
|
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 {
|
if packet.track_id() != track_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoded = match decoder.decode(&packet) {
|
let decoded = match decoder.decode(&packet) {
|
||||||
Ok(buf) => buf,
|
Ok(buf) => buf,
|
||||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||||
@@ -433,7 +586,12 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let spec = *decoded.spec();
|
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 ch = spec.channels.count() as u32;
|
||||||
let sr = spec.rate;
|
let sr = spec.rate;
|
||||||
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
|
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
|
||||||
@@ -452,37 +610,64 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||||
samples.copy_interleaved_ref(decoded);
|
samples.copy_interleaved_ref(decoded);
|
||||||
for &s in samples.samples() {
|
let slice = samples.samples();
|
||||||
let v = (s as f64).abs();
|
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 {
|
if v.is_finite() && v > sample_peak_abs {
|
||||||
sample_peak_abs = v;
|
sample_peak_abs = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some(ref mut ebu) = ebu else {
|
sample_idx += 1;
|
||||||
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
|
}
|
||||||
return None;
|
|
||||||
};
|
if loudness_target_lufs.is_some() {
|
||||||
match ebu.add_frames_f32(samples.samples()) {
|
if let Some(e) = ebu.as_mut() {
|
||||||
|
match e.add_frames_f32(samples.samples()) {
|
||||||
Ok(_) => fed_any_frames = true,
|
Ok(_) => fed_any_frames = true,
|
||||||
Err(e) => {
|
Err(err) => {
|
||||||
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", e);
|
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loop_i = loop_i.wrapping_add(1);
|
loop_i = loop_i.wrapping_add(1);
|
||||||
if loop_i % 128 == 0 {
|
if loop_i % 128 == 0 {
|
||||||
std::thread::yield_now();
|
std::thread::yield_now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bins = normalize_peak_bins(&bin_max);
|
||||||
|
|
||||||
|
let loudness = if let Some(target_lufs) = loudness_target_lufs {
|
||||||
if !fed_any_frames {
|
if !fed_any_frames {
|
||||||
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
|
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let Some(ebu) = ebu else {
|
let Some(ebu) = ebu else {
|
||||||
crate::app_deprintln!("[analysis] loudness failed: no decoder output");
|
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let integrated_lufs = match ebu.loudness_global() {
|
let integrated_lufs = match ebu.loudness_global() {
|
||||||
@@ -510,12 +695,16 @@ fn analyze_loudness_from_audio_bytes(bytes: &[u8], target_lufs: f64) -> Option<(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !true_peak_ok {
|
if !true_peak_ok {
|
||||||
// Fallback to sample peak if true-peak is not available for this stream/codec.
|
|
||||||
true_peak = sample_peak_abs;
|
true_peak = sample_peak_abs;
|
||||||
}
|
}
|
||||||
|
let recommended_gain_db =
|
||||||
let recommended_gain_db = recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
|
recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
|
||||||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
|
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(PcmScanResult { bins, loudness })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analysis_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
fn analysis_db_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
|||||||
+43
-16
@@ -1215,6 +1215,7 @@ async fn ranged_download_task(
|
|||||||
let dl_started = Instant::now();
|
let dl_started = Instant::now();
|
||||||
let mut next_progress_mb: usize = 1;
|
let mut next_progress_mb: usize = 1;
|
||||||
let mut last_partial_emit = Instant::now();
|
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);
|
let mut last_partial_loudness_emit = Instant::now() - Duration::from_secs(5);
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
@@ -1292,10 +1293,14 @@ async fn ranged_download_task(
|
|||||||
downloaded_to.store(downloaded, Ordering::SeqCst);
|
downloaded_to.store(downloaded, Ordering::SeqCst);
|
||||||
if downloaded >= 4096
|
if downloaded >= 4096
|
||||||
&& total_size > 0
|
&& 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)
|
&& last_partial_emit.elapsed() >= partial_waveform_emit_min_interval(downloaded)
|
||||||
{
|
{
|
||||||
let bins =
|
// Keep streaming path lightweight: avoid expensive PCM decode
|
||||||
derive_partial_waveform_bins_short_locks(&buf, downloaded, 500);
|
// 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) {
|
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));
|
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) {
|
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 = Instant::now();
|
||||||
|
last_partial_emit_downloaded = downloaded;
|
||||||
}
|
}
|
||||||
let mb = downloaded / (1024 * 1024);
|
let mb = downloaded / (1024 * 1024);
|
||||||
if mb >= next_progress_mb {
|
if mb >= next_progress_mb {
|
||||||
@@ -1386,11 +1392,11 @@ async fn ranged_download_task(
|
|||||||
fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration {
|
fn partial_waveform_emit_min_interval(downloaded: usize) -> Duration {
|
||||||
const MB: usize = 1024 * 1024;
|
const MB: usize = 1024 * 1024;
|
||||||
if downloaded <= 3 * MB {
|
if downloaded <= 3 * MB {
|
||||||
Duration::from_millis(280)
|
Duration::from_millis(260)
|
||||||
} else if downloaded <= 10 * MB {
|
} else if downloaded <= 10 * MB {
|
||||||
Duration::from_millis(650)
|
Duration::from_millis(620)
|
||||||
} else {
|
} 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.
|
/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
|
||||||
const MASTER_HEADROOM: f32 = 0.891_254;
|
const MASTER_HEADROOM: f32 = 0.891_254;
|
||||||
const PARTIAL_LOUDNESS_MIN_BYTES: usize = 256 * 1024;
|
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
|
/// 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.
|
/// `audio_update_replay_gain(null)` cannot briefly blast louder than this anchor.
|
||||||
const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0;
|
const LOUDNESS_STARTUP_ATTENUATION_DB: f32 = -6.0;
|
||||||
@@ -3688,6 +3695,22 @@ fn spawn_progress_task(
|
|||||||
gapless_switch_at: Arc<AtomicU64>,
|
gapless_switch_at: Arc<AtomicU64>,
|
||||||
current_playback_url: Arc<Mutex<Option<String>>>,
|
current_playback_url: Arc<Mutex<Option<String>>>,
|
||||||
) {
|
) {
|
||||||
|
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 {
|
tokio::spawn(async move {
|
||||||
let mut near_end_ticks: u32 = 0;
|
let mut near_end_ticks: u32 = 0;
|
||||||
// Local done-flag reference; swapped on gapless transition.
|
// 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 samples = samples_played.load(Ordering::Relaxed) as f64;
|
||||||
let divisor = (rate * ch).max(1.0);
|
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();
|
let cur = current_arc.lock().unwrap();
|
||||||
cur.duration_secs
|
(cur.duration_secs, cur.paused_at)
|
||||||
};
|
|
||||||
let is_paused = {
|
|
||||||
let cur = current_arc.lock().unwrap();
|
|
||||||
cur.paused_at.is_some()
|
|
||||||
};
|
};
|
||||||
|
let is_paused = paused_at.is_some();
|
||||||
|
|
||||||
let pos = if is_paused {
|
let pos_raw = if let Some(p) = paused_at {
|
||||||
let cur = current_arc.lock().unwrap();
|
p
|
||||||
cur.paused_at.unwrap_or(0.0)
|
|
||||||
} else {
|
} else {
|
||||||
(samples / divisor).min(dur.max(0.001))
|
(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();
|
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 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 };
|
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;
|
near_end_ticks += 1;
|
||||||
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
|
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
|
||||||
if near_end_ticks >= 10 {
|
if near_end_ticks >= 10 {
|
||||||
|
|||||||
@@ -1258,6 +1258,13 @@ fn analysis_delete_loudness_for_track(
|
|||||||
cache.delete_loudness_for_track_id(&track_id)
|
cache.delete_loudness_for_track_id(&track_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn analysis_delete_all_waveforms(
|
||||||
|
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
cache.delete_all_waveforms()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn analysis_enqueue_seed_from_url(
|
fn analysis_enqueue_seed_from_url(
|
||||||
track_id: String,
|
track_id: String,
|
||||||
@@ -4108,6 +4115,7 @@ pub fn run() {
|
|||||||
analysis_get_waveform_for_track,
|
analysis_get_waveform_for_track,
|
||||||
analysis_get_loudness_for_track,
|
analysis_get_loudness_for_track,
|
||||||
analysis_delete_loudness_for_track,
|
analysis_delete_loudness_for_track,
|
||||||
|
analysis_delete_all_waveforms,
|
||||||
analysis_enqueue_seed_from_url,
|
analysis_enqueue_seed_from_url,
|
||||||
download_track_offline,
|
download_track_offline,
|
||||||
delete_offline_track,
|
delete_offline_track,
|
||||||
|
|||||||
+115
-46
@@ -8,6 +8,8 @@ function fmt(s: number): string {
|
|||||||
|
|
||||||
const BAR_COUNT = 500;
|
const BAR_COUNT = 500;
|
||||||
const SEG_COUNT = 60;
|
const SEG_COUNT = 60;
|
||||||
|
const FLAT_WAVE_NORM = 0.06;
|
||||||
|
const WAVE_MORPH_MS = 1000;
|
||||||
|
|
||||||
// ── animation state ───────────────────────────────────────────────────────────
|
// ── animation state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -96,6 +98,22 @@ export function makeHeights(trackId: string): Float32Array {
|
|||||||
|
|
||||||
// ── draw functions ────────────────────────────────────────────────────────────
|
// ── 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(
|
function drawWaveform(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
heights: Float32Array | null,
|
heights: Float32Array | null,
|
||||||
@@ -108,9 +126,38 @@ function drawWaveform(
|
|||||||
const { played, buffered: buffCol, unplayed } = getColors();
|
const { played, buffered: buffCol, unplayed } = getColors();
|
||||||
|
|
||||||
if (!heights) {
|
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.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;
|
ctx.globalAlpha = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,7 +169,7 @@ function drawWaveform(
|
|||||||
ctx.fillStyle = unplayed;
|
ctx.fillStyle = unplayed;
|
||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
if (i / BAR_COUNT < buffered) continue;
|
if (i / BAR_COUNT < buffered) continue;
|
||||||
const bh = Math.max(1, heights[i] * h);
|
const bh = waveformBarThickness(h, heights[i]);
|
||||||
const x = x1Of(i);
|
const x = x1Of(i);
|
||||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||||
}
|
}
|
||||||
@@ -132,7 +179,7 @@ function drawWaveform(
|
|||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
const frac = i / BAR_COUNT;
|
const frac = i / BAR_COUNT;
|
||||||
if (frac < progress || frac >= buffered) continue;
|
if (frac < progress || frac >= buffered) continue;
|
||||||
const bh = Math.max(1, heights[i] * h);
|
const bh = waveformBarThickness(h, heights[i]);
|
||||||
const x = x1Of(i);
|
const x = x1Of(i);
|
||||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||||
}
|
}
|
||||||
@@ -140,29 +187,14 @@ function drawWaveform(
|
|||||||
if (progress > 0) {
|
if (progress > 0) {
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.fillStyle = played;
|
ctx.fillStyle = played;
|
||||||
ctx.shadowColor = played;
|
|
||||||
ctx.shadowBlur = 5;
|
|
||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
if (i / BAR_COUNT >= progress) break;
|
if (i / BAR_COUNT >= progress) break;
|
||||||
const bh = Math.max(1, heights[i] * h);
|
const bh = waveformBarThickness(h, heights[i]);
|
||||||
const x = x1Of(i);
|
const x = x1Of(i);
|
||||||
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
ctx.fillRect(x, (h - bh) / 2, x2Of(i) - x, bh);
|
||||||
}
|
}
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
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) {
|
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 SEEK_COMMIT_PROGRESS_EPS = 0.02;
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const heightsRef = useRef<Float32Array | null>(null);
|
const heightsRef = useRef<Float32Array | null>(null);
|
||||||
const lastHeightsTrackIdRef = useRef<string | undefined>(undefined);
|
|
||||||
const progressRef = useRef(usePlayerStore.getState().progress);
|
const progressRef = useRef(usePlayerStore.getState().progress);
|
||||||
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
const bufferedRef = useRef(usePlayerStore.getState().buffered);
|
||||||
const isDragging = useRef(false);
|
const isDragging = useRef(false);
|
||||||
@@ -848,43 +879,73 @@ export default function WaveformSeek({ trackId }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!trackId) {
|
if (!trackId) {
|
||||||
heightsRef.current = null;
|
heightsRef.current = null;
|
||||||
lastHeightsTrackIdRef.current = undefined;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (waveformBins && waveformBins.length > 0) {
|
if (waveformBins && waveformBins.length > 0) {
|
||||||
const src = waveformBins;
|
const src = waveformBins;
|
||||||
const h = new Float32Array(BAR_COUNT);
|
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++) {
|
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 idx = Math.min(src.length - 1, Math.floor((i / BAR_COUNT) * src.length));
|
||||||
const v = src[idx];
|
const v = src[idx];
|
||||||
h[i] = Math.max(0.08, Math.min(1, (Number(v) / 255)));
|
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 prev = heightsRef.current;
|
||||||
const mixed = new Float32Array(BAR_COUNT);
|
if (!prev || prev.length !== BAR_COUNT) {
|
||||||
for (let i = 0; i < BAR_COUNT; i++) {
|
|
||||||
mixed[i] = prev[i] * 0.45 + h[i] * 0.55;
|
|
||||||
}
|
|
||||||
heightsRef.current = mixed;
|
|
||||||
} else {
|
|
||||||
heightsRef.current = h;
|
heightsRef.current = h;
|
||||||
}
|
|
||||||
lastHeightsTrackIdRef.current = trackId;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
heightsRef.current = makeHeights(trackId);
|
const from = new Float32Array(prev);
|
||||||
lastHeightsTrackIdRef.current = trackId;
|
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);
|
||||||
|
}
|
||||||
|
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]);
|
}, [trackId, waveformBins, waveformIsPartial, waveformKnownUntilSec, waveformDurationSec, duration]);
|
||||||
|
|
||||||
// Imperative subscription — no React re-renders from progress changes.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
if (ANIMATED_STYLES.has(seekbarStyle)) return;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (canvas) drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.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.
|
// rAF loop — animated styles only.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -656,9 +656,12 @@ export const enTranslation = {
|
|||||||
hotCacheTrackCount: 'Tracks in cache:',
|
hotCacheTrackCount: 'Tracks in cache:',
|
||||||
cacheMaxLabel: 'Max. size',
|
cacheMaxLabel: 'Max. size',
|
||||||
cacheClearBtn: 'Clear Cache',
|
cacheClearBtn: 'Clear Cache',
|
||||||
|
waveformCacheClearBtn: 'Clear waveform cache',
|
||||||
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
cacheClearWarning: 'This will also remove all offline albums from the library.',
|
||||||
cacheClearConfirm: 'Clear Everything',
|
cacheClearConfirm: 'Clear Everything',
|
||||||
cacheClearCancel: 'Cancel',
|
cacheClearCancel: 'Cancel',
|
||||||
|
waveformCacheCleared: 'Waveform cache cleared ({{count}} rows).',
|
||||||
|
waveformCacheClearFailed: 'Failed to clear waveform cache.',
|
||||||
offlineDirTitle: 'Offline Library (In-App)',
|
offlineDirTitle: 'Offline Library (In-App)',
|
||||||
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
offlineDirDesc: 'Storage location for tracks you make available offline within Psysonic.',
|
||||||
offlineDirDefault: 'Default (App Data)',
|
offlineDirDefault: 'Default (App Data)',
|
||||||
|
|||||||
@@ -677,9 +677,12 @@ export const ruTranslation = {
|
|||||||
hotCacheTrackCount: 'Треков в кэше:',
|
hotCacheTrackCount: 'Треков в кэше:',
|
||||||
cacheMaxLabel: 'Лимит',
|
cacheMaxLabel: 'Лимит',
|
||||||
cacheClearBtn: 'Очистить кэш',
|
cacheClearBtn: 'Очистить кэш',
|
||||||
|
waveformCacheClearBtn: 'Очистить кэш waveform',
|
||||||
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
|
cacheClearWarning: 'Будут удалены и все офлайн-альбомы.',
|
||||||
cacheClearConfirm: 'Очистить всё',
|
cacheClearConfirm: 'Очистить всё',
|
||||||
cacheClearCancel: 'Отмена',
|
cacheClearCancel: 'Отмена',
|
||||||
|
waveformCacheCleared: 'Кэш waveform очищен ({{count}} строк).',
|
||||||
|
waveformCacheClearFailed: 'Не удалось очистить кэш waveform.',
|
||||||
offlineDirTitle: 'Офлайн-библиотека (в приложении)',
|
offlineDirTitle: 'Офлайн-библиотека (в приложении)',
|
||||||
offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.',
|
offlineDirDesc: 'Папка для треков, сохранённых для офлайн-прослушивания.',
|
||||||
offlineDirDefault: 'По умолчанию (данные приложения)',
|
offlineDirDefault: 'По умолчанию (данные приложения)',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { open as openUrl } from '@tauri-apps/plugin-shell';
|
|||||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||||
import LastfmIcon from '../components/LastfmIcon';
|
import LastfmIcon from '../components/LastfmIcon';
|
||||||
import CustomSelect from '../components/CustomSelect';
|
import CustomSelect from '../components/CustomSelect';
|
||||||
@@ -1788,6 +1789,29 @@ export default function Settings() {
|
|||||||
setClearing(false);
|
setClearing(false);
|
||||||
}, [clearAllOffline, serverId]);
|
}, [clearAllOffline, serverId]);
|
||||||
|
|
||||||
|
const handleClearWaveformCache = useCallback(async () => {
|
||||||
|
setClearing(true);
|
||||||
|
try {
|
||||||
|
const deleted = await invoke<number>('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 () => {
|
const startLastfmConnect = useCallback(async () => {
|
||||||
setLfmError(null);
|
setLfmError(null);
|
||||||
let token: string;
|
let token: string;
|
||||||
@@ -3032,6 +3056,16 @@ export default function Settings() {
|
|||||||
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
onClick={handleClearWaveformCache}
|
||||||
|
disabled={clearing}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> {t('settings.waveformCacheClearBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSubSection>
|
</SettingsSubSection>
|
||||||
|
|
||||||
|
|||||||
+36
-15
@@ -824,19 +824,11 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
const dur = duration > 0 ? duration : track.duration;
|
const dur = duration > 0 ? duration : track.duration;
|
||||||
if (dur <= 0) return;
|
if (dur <= 0) return;
|
||||||
const progress = displayTime / dur;
|
const progress = displayTime / dur;
|
||||||
const stateNow = usePlayerStore.getState();
|
// Do not couple `waveformKnownUntilSec` to playhead: for partial streams it
|
||||||
if (stateNow.waveformIsPartial) {
|
// must track **download/analysis progress** (emitted in `analysis:waveform-partial`).
|
||||||
usePlayerStore.setState({
|
// Using `displayTime` here broke the seekbar after seeks — bins only cover the
|
||||||
currentTime: displayTime,
|
// buffered prefix of the file while `knownUntil` jumped with the seek position.
|
||||||
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 });
|
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||||||
}
|
|
||||||
|
|
||||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||||
if (progress >= 0.5 && !store.scrobbled) {
|
if (progress >= 0.5 && !store.scrobbled) {
|
||||||
@@ -1123,11 +1115,37 @@ export function initAudioListeners(): () => void {
|
|||||||
usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 });
|
usePlayerStore.setState({ waveformIsPartial: false, waveformKnownUntilSec: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
usePlayerStore.setState({
|
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,
|
waveformBins: payload.bins,
|
||||||
waveformIsPartial: true,
|
waveformIsPartial: true,
|
||||||
waveformKnownUntilSec: Number.isFinite(payload.knownUntilSec) ? payload.knownUntilSec : 0,
|
waveformKnownUntilSec: nextKnownUntilSec,
|
||||||
waveformDurationSec: Number.isFinite(payload.durationSec) ? payload.durationSec : (current.duration || 0),
|
waveformDurationSec: nextDurationSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const len = Math.max(prevBins.length, payload.bins.length);
|
||||||
|
const merged = new Array<number>(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 }) => {
|
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;
|
if (!payloadTrackId) return;
|
||||||
const currentId = usePlayerStore.getState().currentTrack?.id;
|
const currentId = usePlayerStore.getState().currentTrack?.id;
|
||||||
if (currentId && payloadTrackId === currentId) {
|
if (currentId && payloadTrackId === currentId) {
|
||||||
|
if (!payload.isPartial) {
|
||||||
|
usePlayerStore.setState({ waveformIsPartial: false });
|
||||||
|
}
|
||||||
void refreshWaveformForTrack(currentId);
|
void refreshWaveformForTrack(currentId);
|
||||||
void refreshLoudnessForTrack(currentId);
|
void refreshLoudnessForTrack(currentId);
|
||||||
emitNormalizationDebug('backfill:applied', { trackId: currentId });
|
emitNormalizationDebug('backfill:applied', { trackId: currentId });
|
||||||
|
|||||||
Reference in New Issue
Block a user