feat(enrichment): oximedia BPM/mood facts, mood search, and queue display (#863)

* feat(enrichment): oximedia BPM/mood facts, mood search, and queue UI

Run client-side oximedia analysis after CPU seed and persist BPM, mood JSON,
and searchable mood_tag facts. Add product mood groups (joy/sadness/dance/work/
romance) with Advanced Search filter on the local index, queue BPM/mood display,
migration 008 mood_tag index, and refreshed licenses for oximedia crates.

* fix(enrichment): keep mood_groups module comment in English

* feat(search): virtual mood groups, anger filter, and Advanced Search UX

Expand mood search via overlapping virtual groups (tag expansion only),
add anger/Злость group, skip album/artist shortcuts for track-only filters,
and simplify mood search UI (songs-only, hide type tabs). Fix CustomSelect
spurious scrollbar on short option lists.

* feat(analysis): unified track analysis plan and enqueue path

Add TrackAnalysisPlan (waveform, LUFS, enrichment) with a single
enqueue_track_analysis entry for all byte-backed triggers. Run enrichment
when cache is full but library facts are missing; route playback, cache,
and backfill through the planner. Fix browseTextSearch LocalSearchOpts tsc
gap and remove obsolete read_seed_bytes_if_needed helper.

* fix(analysis): wire playback dispatch, preload enrichment, and UI refresh

Route stream, gapless, preload, and local-file playback through analysis_dispatch
so BPM/mood enrichment runs when waveform/LUFS are already cached. Fix audio_preload
cache-hit and hot-cache paths, emit preload-cancelled for retry, and add
analysis:enrichment-updated plus content_cache_coverage key resolution.

* fix(audio): preload local files from disk and stop analysis retry loop

Seed hot/offline next tracks via LocalFilePlayback (512 MiB) instead of copying
into the RAM preload slot. Keep bytePreloadingId set after preload-ready so
progress ticks do not re-invoke audio_preload every second.

* fix(enrichment): clippy, album bpm filter routing, and queue mood display

Clippy-clean analysis_dispatch and engine imports; restrict track-derived album
routing to mood_group/mood_tag only so bpm is skipped on album queries. Log
enrichment plan errors with retry-all plan; filter queue mood labels to oximedia ids.

* fix(enrichment): simplify mood_tag backfill branch in plan_track_enrichment

Remove empty if-block; keep same behaviour when backfill fails and moods row exists.

* docs: CHANGELOG and credits for track enrichment PR #863

* docs(credits): track enrichment PR #863 contributor line

* chore(enrichment): clippy-clean plan branch and trim dead exports

Collapse mood_tag backfill if for clippy; remove unused moodGroupById and
OXIMEDIA_MOOD_LABELS re-exports; stop poll when server BPM is already known.

* fix(enrichment): close R2/S1–S3 limits and Song Info BPM fallback

Return TrackEnrichmentOutcome::Failed on oximedia errors so retries are not
masked as complete; extract mood Advanced Search SQL, unify top-3 mood tag
selection in mood_groups with TS invariant tests, and show measured BPM in
Song Info when tag BPM is missing or zero.

* fix(enrichment): restore offline coverage and show mood in Song Info

Add unit tests for offline download cancel/clear registry after the analysis
seed refactor dropped read_seed_bytes coverage; show localized mood labels in
Song Info when library enrichment facts exist.

* fix(enrichment): soft mood scoring and unblock offline cancel tests

Replace oximedia quadrant happy/excited mapping with valence/arousal
soft scores across all mood tags for display, storage, and backfill;
fix offline cancel unit tests that deadlocked by calling clear while
holding the global offline_cancel_flags mutex.

* fix(enrichment): dedupe joy cluster and cap mood display at two labels

Never show happy and excited together; pick one tag per V/A cluster,
tighten oximedia recalibration, and limit queue/Song Info to two moods
that pass a relative score floor.

* fix(enrichment): disable oximedia mood labels in UI and search tags

Oximedia 0.1.7 mood is a spectral energy heuristic, not independent mood
weights; valence correlates with loud/bright audio and false-labels metal
and lyrical tracks as happy. Hide queue/Song Info mood and stop writing
mood_tag facts until a reliable detector lands; keep V/A/moods JSON stored.

* fix(enrichment): disable oximedia mood analysis and add BPM advanced search

Stop planning, running, and storing oximedia mood facts; purge accumulated
mood rows via migration 009. Hide mood filters in Advanced Search, expose
BPM range filter with dual-storage resolution, and show a BPM column in song
results when that filter is active.

* feat(search): analysis BPM priority, source tooltip, and filter UX

Prefer analysis track_fact over file tags for BPM resolution; show source
in list tooltips. Validate BPM range on blur, add clear button, fix double tooltip.

* fix(enrichment): prefer analysis BPM in Song Info and queue tech row

Show measured track_fact BPM before file tags until analysis completes;
pick the highest-confidence analysis fact when several exist.
This commit is contained in:
cucadmuh
2026-05-23 18:54:04 +03:00
committed by GitHub
parent 67b1dc790b
commit 003b280a77
92 changed files with 4566 additions and 492 deletions
@@ -0,0 +1,242 @@
//! Unified playback → track analysis dispatch.
//!
//! Stream completion, hot/offline files, gapless chain, preload, and in-memory
//! replay all funnel through here before [`psysonic_analysis::analysis_runtime::enqueue_track_analysis`].
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tauri::{AppHandle, Manager};
use crate::engine::{analysis_track_id_is_current_playback, AudioEngine};
use crate::helpers::{analysis_cache_track_id, current_playback_server_id_str};
use crate::state::ChainedInfo;
use crate::stream::{LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES, TRACK_STREAM_PROMOTE_MAX_BYTES};
/// Where playback obtained the bytes — used for logging and size caps only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TrackAnalysisOrigin {
InMemoryReplay,
StreamDownloadComplete,
LocalFilePlayback,
StreamSpillFile,
PrefetchOrCacheFile,
GaplessChainReady,
GaplessTransition,
}
fn max_bytes_for_origin(origin: TrackAnalysisOrigin) -> usize {
match origin {
TrackAnalysisOrigin::LocalFilePlayback => LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
_ => TRACK_STREAM_PROMOTE_MAX_BYTES,
}
}
/// Playback server scope: explicit IPC value, else pinned engine scope.
pub(crate) fn resolve_analysis_server_id(
explicit: Option<&str>,
engine: Option<&AudioEngine>,
) -> String {
explicit
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
engine
.map(current_playback_server_id_str)
.unwrap_or_default()
})
}
fn resolve_high_priority(
app: &AppHandle,
engine: Option<&AudioEngine>,
track_id: &str,
explicit: Option<bool>,
) -> bool {
explicit.unwrap_or_else(|| {
psysonic_analysis::analysis_runtime::analysis_backfill_is_current_track(app, track_id)
|| engine.is_some_and(|e| analysis_track_id_is_current_playback(e, track_id))
})
}
/// Resolve `(server_id, high_priority)` when the caller has live engine state.
pub(crate) fn prepare_playback_analysis(
app: &AppHandle,
engine: &AudioEngine,
explicit_server_id: Option<&str>,
track_id: &str,
high_priority: Option<bool>,
) -> (String, bool) {
(
resolve_analysis_server_id(explicit_server_id, Some(engine)),
resolve_high_priority(app, Some(engine), track_id, high_priority),
)
}
pub(crate) fn resolve_server_id_for_app(
app: &AppHandle,
explicit: Option<&str>,
) -> String {
let engine = app.try_state::<AudioEngine>();
resolve_analysis_server_id(explicit, engine.as_deref())
}
pub(crate) fn high_priority_for_app(
app: &AppHandle,
track_id: &str,
explicit: Option<bool>,
) -> bool {
let engine = app.try_state::<AudioEngine>();
resolve_high_priority(app, engine.as_deref(), track_id, explicit)
}
/// Gapless boundary: chained track became audible — run unified analysis if needed.
pub(crate) fn spawn_gapless_transition_analysis(app: &AppHandle, info: &ChainedInfo) {
let track_id = analysis_cache_track_id(
info.analysis_track_id.as_deref(),
&info.url,
);
let Some(track_id) = track_id else {
return;
};
let engine = app.state::<AudioEngine>();
let (sid, high) = prepare_playback_analysis(
app,
&engine,
info.server_id.as_deref(),
&track_id,
Some(true),
);
let bytes = (*info.raw_bytes).clone();
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::GaplessTransition,
sid,
track_id,
bytes,
high,
None,
);
}
/// Byte-backed analysis — the single audio-side entry before the analysis crate planner.
pub(crate) async fn dispatch_track_analysis_bytes(
app: &AppHandle,
origin: TrackAnalysisOrigin,
server_id: &str,
track_id: &str,
bytes: Vec<u8>,
high_priority: bool,
) -> Result<(), String> {
let track_id = track_id.trim();
if track_id.is_empty() {
return Ok(());
}
if bytes.is_empty() {
return Ok(());
}
let max = max_bytes_for_origin(origin);
if bytes.len() > max {
crate::app_deprintln!(
"[analysis][dispatch] skip origin={origin:?} track_id={track_id} bytes={} max={max}",
bytes.len(),
);
return Ok(());
}
crate::app_deprintln!(
"[analysis][dispatch] origin={origin:?} track_id={track_id} server_id={} size_mib={:.2} high={high_priority}",
if server_id.is_empty() { "''" } else { server_id },
bytes.len() as f64 / (1024.0 * 1024.0),
);
psysonic_analysis::analysis_runtime::enqueue_track_analysis(
app,
server_id,
track_id,
&bytes,
high_priority,
)
.await
.map(|_| ())
}
/// Non-blocking wrapper with optional play-generation supersede guard.
pub(crate) fn spawn_track_analysis_bytes(
app: AppHandle,
origin: TrackAnalysisOrigin,
server_id: String,
track_id: String,
bytes: Vec<u8>,
high_priority: bool,
generation_guard: Option<(u64, Arc<AtomicU64>)>,
) {
if track_id.trim().is_empty() || bytes.is_empty() {
return;
}
tokio::spawn(async move {
if let Some((gen, gen_arc)) = generation_guard {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
}
if let Err(e) = dispatch_track_analysis_bytes(
&app,
origin,
&server_id,
&track_id,
bytes,
high_priority,
)
.await
{
crate::app_eprintln!(
"[analysis][dispatch] failed origin={origin:?} track_id={track_id}: {e}"
);
}
});
}
pub(crate) fn spawn_track_analysis_file(
app: AppHandle,
origin: TrackAnalysisOrigin,
server_id: String,
track_id: String,
file_path: PathBuf,
high_priority: bool,
generation_guard: Option<(u64, Arc<AtomicU64>)>,
) {
if track_id.trim().is_empty() {
return;
}
tokio::spawn(async move {
if let Some((gen, gen_arc)) = &generation_guard {
if gen_arc.load(Ordering::SeqCst) != *gen {
return;
}
}
let bytes = match tokio::fs::read(&file_path).await {
Ok(b) if !b.is_empty() => b,
_ => return,
};
if let Some((gen, gen_arc)) = generation_guard {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
}
if let Err(e) = dispatch_track_analysis_bytes(
&app,
origin,
&server_id,
&track_id,
bytes,
high_priority,
)
.await
{
crate::app_eprintln!(
"[analysis][dispatch] file failed origin={origin:?} track_id={track_id}: {e}"
);
}
});
}
@@ -437,6 +437,7 @@ pub async fn audio_play(
}
// ── Progress + ended detection ────────────────────────────────────────────
let analysis_app = app.clone();
spawn_progress_task(
gen,
state.generation.clone(),
@@ -446,6 +447,7 @@ pub async fn audio_play(
state.crossfade_secs.clone(),
done_flag,
app,
Some(analysis_app),
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
@@ -480,6 +482,7 @@ pub async fn audio_chain_preload(
fallback_db: f32,
hi_res_enabled: bool,
analysis_track_id: Option<String>,
server_id: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -542,6 +545,27 @@ pub async fn audio_chain_preload(
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let analysis_server_id = server_id.as_deref().map(str::trim).filter(|s| !s.is_empty());
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
let (sid, high) = crate::analysis_dispatch::prepare_playback_analysis(
&app,
&state,
analysis_server_id,
&track_id,
Some(false),
);
let bytes = (*raw_bytes).clone();
crate::analysis_dispatch::spawn_track_analysis_bytes(
app.clone(),
crate::analysis_dispatch::TrackAnalysisOrigin::GaplessChainReady,
sid,
track_id,
bytes,
high,
None,
);
}
// Only `gain_linear` is needed — `effective_volume` is intentionally NOT
// applied to the Sink here. `audio_chain_preload` runs ~30 s before the
@@ -623,6 +647,8 @@ pub async fn audio_chain_preload(
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
url,
analysis_track_id: logical_trim,
server_id: analysis_server_id.map(str::to_string),
raw_bytes,
duration_secs,
replay_gain_linear: gain_linear,
@@ -245,6 +245,7 @@ pub(crate) async fn try_resume_after_device_change(
// Inform the frontend of the new duration (keeps seekbar range correct).
app.emit("audio:playing", ps.built.duration_secs).ok();
let analysis_app = app.clone();
spawn_progress_task(
gen,
engine.generation.clone(),
@@ -254,6 +255,7 @@ pub(crate) async fn try_resume_after_device_change(
engine.crossfade_secs.clone(),
done_flag,
app.clone(),
Some(analysis_app),
engine.samples_played.clone(),
engine.current_sample_rate.clone(),
engine.current_channels.clone(),
@@ -4,7 +4,6 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use rodio::Player;
use tauri::{AppHandle, Manager};
use super::state::{ChainedInfo, PreloadedTrack, StreamCompletedSpill};
@@ -459,7 +458,3 @@ pub fn refresh_http_user_agent(state: &AudioEngine, ua: &str) {
*slot = client;
}
}
pub(crate) fn analysis_seed_high_priority_for_track(app: &AppHandle, track_id: &str) -> bool {
app.try_state::<AudioEngine>()
.is_some_and(|e| analysis_track_id_is_current_playback(&e, track_id))
}
@@ -747,118 +747,6 @@ pub(crate) async fn fetch_data(
Ok(Some(data))
}
/// When playback uses full track bytes already in RAM (gapless `reuse_chained_bytes`,
/// `preloaded`, or `stream_completed_cache` via `fetch_data`), the `psysonic-local`
/// disk-read seed path never runs. Submit the same full-buffer analysis via the cpu-seed queue so waveform /
/// loudness SQLite can fill **offline** without `analysis_enqueue_seed_from_url` HTTP.
pub(crate) fn spawn_analysis_seed_from_in_memory_bytes(
app: &AppHandle,
server_id: Option<&str>,
cache_track_id: Option<&str>,
gen: u64,
gen_arc: &Arc<AtomicU64>,
bytes: &[u8],
) {
let Some(track_id) = cache_track_id.map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
if bytes.is_empty() || bytes.len() > crate::stream::TRACK_STREAM_PROMOTE_MAX_BYTES {
return;
}
let track_id = track_id.to_string();
let server_id = server_id.unwrap_or("").to_string();
let bytes = bytes.to_vec();
let app = app.clone();
let gen_arc = gen_arc.clone();
crate::app_deprintln!(
"[stream] in-memory play path: scheduling full-track analysis track_id={} size_mib={:.2}",
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), server_id, track_id.clone(), bytes, high).await {
crate::app_eprintln!(
"[analysis] in-memory play path seed failed for {}: {}",
track_id,
e
);
}
});
}
/// Full-track analysis for a completed ranged stream spilled to disk (> RAM promote cap).
pub(crate) fn spawn_analysis_seed_from_spill_file(
app: &AppHandle,
server_id: Option<&str>,
track_id: &str,
spill_path: std::path::PathBuf,
gen: u64,
gen_arc: &Arc<AtomicU64>,
) {
let track_id = track_id.trim().to_string();
if track_id.is_empty() {
return;
}
let server_id = server_id.unwrap_or("").to_string();
let app = app.clone();
let gen_arc = gen_arc.clone();
let max_bytes = crate::stream::LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES;
tokio::spawn(async move {
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
let bytes = match tokio::fs::read(&spill_path).await {
Ok(b) if b.is_empty() => return,
Ok(b) if b.len() > max_bytes => {
crate::app_deprintln!(
"[stream] spill analysis skip track_id={} bytes={} max={}",
track_id,
b.len(),
max_bytes
);
return;
}
Ok(b) => b,
Err(e) => {
crate::app_eprintln!(
"[stream] spill analysis read failed track_id={}: {}",
track_id,
e
);
return;
}
};
if gen_arc.load(Ordering::SeqCst) != gen {
return;
}
crate::app_deprintln!(
"[stream] spill path: scheduling full-track analysis track_id={} size_mib={:.2}",
track_id,
bytes.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(
app,
server_id,
track_id.clone(),
bytes,
high,
)
.await
{
crate::app_eprintln!(
"[analysis] spill path seed failed for {}: {}",
track_id,
e
);
}
});
}
/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
@@ -7,6 +7,7 @@
pub use psysonic_core::{app_deprintln, app_eprintln, logging};
pub mod autoeq_commands;
mod analysis_dispatch;
mod codec;
pub mod commands;
mod decode;
@@ -9,19 +9,23 @@ use std::time::Duration;
use ringbuf::traits::Split;
use ringbuf::{HeapCons, HeapRb};
use symphonia::core::io::MediaSource;
use tauri::{AppHandle, Emitter, Manager, State};
use tauri::{AppHandle, Emitter, State};
use super::analysis_dispatch::{
prepare_playback_analysis, spawn_track_analysis_bytes, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::decode::{build_source, build_streaming_source, BuiltSource, SizedDecoder};
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{
content_type_to_hint, fetch_data, format_hint_from_content_disposition,
normalize_stream_suffix_for_hint, resolve_playback_format_hint, sniff_stream_format_extension,
spawn_analysis_seed_from_in_memory_bytes, same_playback_target,
same_playback_target,
STREAM_FORMAT_SNIFF_PROBE_BYTES,
};
use super::stream::{
ranged_download_task, track_download_task, AudioStreamReader,
LocalFileSource, RangedHttpSource, LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES,
LocalFileSource, RangedHttpSource,
TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_BUF_CAPACITY, TRACK_STREAM_MIN_BUF_CAPACITY,
};
@@ -62,6 +66,33 @@ pub(super) struct PlayInputContext<'a> {
pub reuse_chained_bytes: Option<Vec<u8>>,
}
fn spawn_playback_analysis_bytes(
app: &AppHandle,
state: &State<'_, AudioEngine>,
ctx: &PlayInputContext<'_>,
origin: TrackAnalysisOrigin,
bytes: Vec<u8>,
) {
let Some(track_id) = ctx
.cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return;
};
let (sid, high) =
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
origin,
sid,
track_id.to_string(),
bytes,
high,
Some((ctx.gen, state.generation.clone())),
);
}
/// Resolves the play input for `audio_play` honouring (in priority order):
/// 1. Reused chained bytes — manual skip onto pre-chained track.
/// 2. `psysonic-local://` files — open as seekable LocalFileSource.
@@ -77,14 +108,23 @@ pub(super) async fn select_play_input(
app: &AppHandle,
) -> Result<Option<PlayInput>, String> {
if let Some(d) = ctx.reuse_chained_bytes {
spawn_analysis_seed_from_in_memory_bytes(
app,
ctx.server_id,
ctx.cache_id_for_tasks,
ctx.gen,
&state.generation,
&d,
);
if let Some(track_id) = ctx
.cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
{
let (sid, high) =
prepare_playback_analysis(app, state, ctx.server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::InMemoryReplay,
sid,
track_id.to_string(),
d.clone(),
high,
Some((ctx.gen, state.generation.clone())),
);
}
return Ok(Some(PlayInput::Bytes(d)));
}
@@ -114,13 +154,12 @@ pub(super) async fn select_play_input(
Some(d) => d,
None => return Ok(None), // superseded while downloading
};
spawn_analysis_seed_from_in_memory_bytes(
spawn_playback_analysis_bytes(
app,
ctx.server_id,
ctx.cache_id_for_tasks,
ctx.gen,
&state.generation,
&data,
state,
&ctx,
TrackAnalysisOrigin::InMemoryReplay,
data.clone(),
);
Ok(Some(PlayInput::Bytes(data)))
}
@@ -146,57 +185,17 @@ fn open_local_file_input(
local_hint
);
if let Some(seed_id) = ctx.cache_id_for_tasks {
let skip_cpu_seed = app
.try_state::<psysonic_analysis::analysis_cache::AnalysisCache>()
.map(|c| {
c.cpu_seed_redundant_for_track(ctx.server_id.unwrap_or(""), seed_id)
.unwrap_or(false)
})
.unwrap_or(false);
if !skip_cpu_seed {
let path_owned = std::path::PathBuf::from(path);
let app_seed = app.clone();
let gen_seed = ctx.gen;
let gen_arc_seed = state.generation.clone();
let seed_id = seed_id.to_string();
let seed_server = ctx.server_id.unwrap_or("").to_string();
tokio::spawn(async move {
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
let data = match tokio::fs::read(&path_owned).await {
Ok(d) => d,
Err(_) => return,
};
if gen_arc_seed.load(Ordering::SeqCst) != gen_seed {
return;
}
if data.is_empty() || data.len() > LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES {
crate::app_deprintln!(
"[stream] psysonic-local: skip analysis seed track_id={} bytes={} (over {} MiB cap)",
seed_id,
data.len(),
LOCAL_FILE_PLAYBACK_SEED_MAX_BYTES / (1024 * 1024)
);
return;
}
crate::app_deprintln!(
"[stream] psysonic-local: file read complete track_id={} size_mib={:.2} — full-track analysis (cpu-seed queue)",
seed_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app_seed, &seed_id);
if let Err(e) =
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app_seed.clone(), seed_server.clone(), seed_id.clone(), data, high).await
{
crate::app_eprintln!(
"[analysis] local-file seed failed for {}: {}",
seed_id,
e
);
}
});
}
let (sid, high) =
prepare_playback_analysis(app, state, ctx.server_id, seed_id, None);
spawn_track_analysis_file(
app.clone(),
TrackAnalysisOrigin::LocalFilePlayback,
sid,
seed_id.to_string(),
std::path::PathBuf::from(path),
high,
Some((ctx.gen, state.generation.clone())),
);
}
let reader = LocalFileSource { file, len };
Ok(PlayInput::SeekableMedia {
@@ -762,14 +761,22 @@ pub(crate) async fn build_playback_source_with_probe_fallback(
effective_hint
);
}
spawn_analysis_seed_from_in_memory_bytes(
app,
server_id,
cache_id_for_tasks,
gen,
&state.generation,
&data,
);
if let Some(track_id) = cache_id_for_tasks
.map(str::trim)
.filter(|s| !s.is_empty())
{
let (sid, high) =
prepare_playback_analysis(app, state, server_id, track_id, None);
spawn_track_analysis_bytes(
app.clone(),
TrackAnalysisOrigin::StreamDownloadComplete,
sid,
track_id.to_string(),
data.clone(),
high,
Some((gen, state.generation.clone())),
);
}
match build_source_from_play_input(
PlayInput::Bytes(data.clone()),
state,
@@ -3,15 +3,115 @@
//! (which constructs the gapless source chain) and `audio_play` (which
//! starts playback). All three live in this audio submodule.
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use super::analysis_dispatch::{
dispatch_track_analysis_bytes, prepare_playback_analysis, spawn_track_analysis_file,
TrackAnalysisOrigin,
};
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::{analysis_cache_track_id, same_playback_target};
use super::state::PreloadedTrack;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PreloadEventPayload {
url: String,
track_id: Option<String>,
}
async fn seed_preload_analysis_bytes(
app: &AppHandle,
state: &State<'_, AudioEngine>,
url: &str,
data: &[u8],
analysis_track_id: Option<&str>,
server_id: Option<&str>,
) {
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
return;
};
let (sid, high) = prepare_playback_analysis(
app,
state,
server_id,
&track_id,
// Next-track prefetch — never steal CPU from the audible track.
Some(false),
);
if let Err(e) = dispatch_track_analysis_bytes(
app,
TrackAnalysisOrigin::PrefetchOrCacheFile,
&sid,
&track_id,
data.to_vec(),
high,
)
.await
{
crate::app_eprintln!("[analysis] preload seed failed for {track_id}: {e}");
}
}
fn seed_preload_analysis_file(
app: &AppHandle,
state: &State<'_, AudioEngine>,
url: &str,
file_path: PathBuf,
analysis_track_id: Option<&str>,
server_id: Option<&str>,
) {
let Some(track_id) = analysis_cache_track_id(analysis_track_id, url) else {
return;
};
let (sid, high) = prepare_playback_analysis(
app,
state,
server_id,
&track_id,
Some(false),
);
crate::app_deprintln!(
"[stream] audio_preload: local file analysis track_id={} path={}",
track_id,
file_path.display()
);
spawn_track_analysis_file(
app.clone(),
TrackAnalysisOrigin::LocalFilePlayback,
sid,
track_id,
file_path,
high,
None,
);
}
fn emit_preload_ready(app: &AppHandle, url: String, track_id: Option<String>) {
let _ = app.emit(
"audio:preload-ready",
PreloadEventPayload {
url,
track_id,
},
);
}
fn emit_preload_cancelled(app: &AppHandle, url: String, track_id: Option<String>) {
let _ = app.emit(
"audio:preload-cancelled",
PreloadEventPayload {
url,
track_id,
},
);
}
#[tauri::command]
pub async fn audio_preload(
url: String,
@@ -21,49 +121,96 @@ pub async fn audio_preload(
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let track_id_for_events = logical_trim.clone();
let is_local = url.starts_with("psysonic-local://");
// Hot/offline cache: playback reads from disk — seed analysis from the file
// (512 MiB cap) without copying into the RAM preload slot.
if is_local {
let path = PathBuf::from(url.strip_prefix("psysonic-local://").unwrap());
if !path.is_file() {
crate::app_deprintln!(
"[stream] audio_preload: local file missing path={}",
path.display()
);
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
}
seed_preload_analysis_file(
&app,
&state,
&url,
path,
logical_trim.as_deref(),
server_id.as_deref(),
);
emit_preload_ready(&app, url, track_id_for_events);
return Ok(());
}
// Remote URL — reuse in-memory bytes when a prior HTTP preload finished.
{
let preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
let _ = app.emit("audio:preload-ready", url.clone());
let cached = {
let preloaded = state.preloaded.lock().unwrap();
preloaded
.as_ref()
.filter(|p| same_playback_target(&p.url, &url))
.map(|p| p.data.clone())
};
if let Some(data) = cached {
if !data.is_empty() {
seed_preload_analysis_bytes(
&app,
&state,
&url,
&data,
logical_trim.as_deref(),
server_id.as_deref(),
)
.await;
}
return Ok(());
}
}
let _ = duration_hint; // kept in API for compatibility
// Throttle: wait 8 s before starting the background download so it does not
// compete with the decode + sink-feed work of the just-started current track.
// If the user skips during the wait the generation counter changes and we abort.
let gen_snapshot = state.generation.load(Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
}
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else {
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(());
}
response.bytes().await.map_err(|e| e.to_string())?.into()
};
let _ = duration_hint; // kept in API for compatibility
let logical_trim = analysis_track_id
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(track_id) = analysis_cache_track_id(logical_trim.as_deref(), &url) {
crate::app_deprintln!(
"[stream] audio_preload: bytes ready track_id={} size_mib={:.2} — invoking full-track analysis",
track_id,
data.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_track_id_is_current_playback(&state, &track_id);
let sid = server_id.clone().unwrap_or_default();
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] preload seed failed for {}: {}", track_id, e);
}
let response = audio_http_client(&state).get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
emit_preload_cancelled(&app, url, track_id_for_events);
return Ok(());
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
if !data.is_empty() {
seed_preload_analysis_bytes(
&app,
&state,
&url,
&data,
logical_trim.as_deref(),
server_id.as_deref(),
)
.await;
}
let url_for_emit = url.clone();
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
let _ = app.emit("audio:preload-ready", url_for_emit);
emit_preload_ready(&app, url_for_emit, track_id_for_events);
Ok(())
}
@@ -63,6 +63,7 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>,
emitter: E,
analysis_app: Option<AppHandle>,
samples_played: Arc<AtomicU64>,
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
@@ -134,6 +135,10 @@ pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
if let Some(app) = analysis_app.clone() {
crate::analysis_dispatch::spawn_gapless_transition_analysis(&app, &info);
}
// Swap to the chained source's done flag.
current_done = info.source_done;
@@ -381,6 +386,7 @@ mod tests {
self.crossfade_secs.clone(),
self.done.clone(),
emitter,
None,
self.samples_played.clone(),
self.sample_rate.clone(),
self.channels.clone(),
@@ -518,6 +524,8 @@ mod tests {
let chained_samples = Arc::new(AtomicU64::new(0));
*h.chained.lock().unwrap() = Some(ChainedInfo {
url: chain_url.clone(),
analysis_track_id: None,
server_id: None,
raw_bytes: Arc::new(Vec::new()),
duration_secs: 200.0,
replay_gain_linear: 1.0,
@@ -185,6 +185,7 @@ pub async fn audio_play_radio(
state.crossfade_secs.clone(),
done_flag,
app,
None,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
@@ -18,6 +18,10 @@ pub(crate) struct StreamCompletedSpill {
pub(crate) struct ChainedInfo {
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
pub(crate) url: String,
/// Subsonic track id for analysis dispatch (from `audio_chain_preload`).
pub(crate) analysis_track_id: Option<String>,
/// Playback server scope for analysis writes.
pub(crate) server_id: Option<String>,
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
/// them instead of re-downloading after dropping the Sink queue.
pub(crate) raw_bytes: Arc<Vec<u8>>,
@@ -26,9 +26,11 @@ use super::{
RADIO_YIELD_MS, TRACK_READ_TIMEOUT_SECS, TRACK_STREAM_MAX_RECONNECTS,
TRACK_STREAM_PROMOTE_MAX_BYTES,
};
use crate::helpers::{
install_stream_completed_spill, spawn_analysis_seed_from_spill_file, write_stream_spill_file,
use crate::analysis_dispatch::{
dispatch_track_analysis_bytes, high_priority_for_app, resolve_server_id_for_app,
spawn_track_analysis_file, TrackAnalysisOrigin,
};
use crate::helpers::{install_stream_completed_spill, write_stream_spill_file};
use crate::state::StreamCompletedSpill;
/// Clears `AudioEngine::ranged_loudness_seed_hold` only if it still matches this play.
@@ -644,10 +646,19 @@ pub(crate) async fn ranged_download_task(
);
}
if let Some(track_id) = cache_track_id {
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
let sid = server_id.clone().unwrap_or_default();
if let Err(e) = psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), data.clone(), high).await {
crate::app_eprintln!("[analysis] ranged seed failed for {}: {}", track_id, e);
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
let high = high_priority_for_app(&app, &track_id, None);
if let Err(e) = dispatch_track_analysis_bytes(
&app,
TrackAnalysisOrigin::StreamDownloadComplete,
&sid,
&track_id,
data.clone(),
high,
)
.await
{
crate::app_eprintln!("[analysis] ranged seed failed for {track_id}: {e}");
}
}
if gen_arc.load(Ordering::SeqCst) != gen {
@@ -679,13 +690,16 @@ pub(crate) async fn ranged_download_task(
return;
}
install_stream_completed_spill(&spill_cache_slot, url, path.clone());
spawn_analysis_seed_from_spill_file(
&app,
server_id.as_deref(),
&track_id,
let sid = resolve_server_id_for_app(&app, server_id.as_deref());
let high = high_priority_for_app(&app, &track_id, None);
spawn_track_analysis_file(
app.clone(),
TrackAnalysisOrigin::StreamSpillFile,
sid,
track_id,
path,
gen,
&gen_arc,
high,
Some((gen, gen_arc.clone())),
);
}
Err(e) => {
@@ -167,12 +167,22 @@ pub(crate) async fn track_download_task(
track_id,
capture.len() as f64 / (1024.0 * 1024.0)
);
let high = crate::engine::analysis_seed_high_priority_for_track(&app, &track_id);
let sid = server_id.clone().unwrap_or_default();
if let Err(e) =
psysonic_analysis::analysis_runtime::submit_analysis_cpu_seed(app.clone(), sid, track_id.clone(), capture.clone(), high).await
let sid = crate::analysis_dispatch::resolve_server_id_for_app(
&app,
server_id.as_deref(),
);
let high = crate::analysis_dispatch::high_priority_for_app(&app, &track_id, None);
if let Err(e) = crate::analysis_dispatch::dispatch_track_analysis_bytes(
&app,
crate::analysis_dispatch::TrackAnalysisOrigin::StreamDownloadComplete,
&sid,
&track_id,
capture.clone(),
high,
)
.await
{
crate::app_eprintln!("[analysis] track seed failed for {}: {}", track_id, e);
crate::app_eprintln!("[analysis] track seed failed for {track_id}: {e}");
}
}
if gen_arc.load(Ordering::SeqCst) != gen {