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
@@ -5,10 +5,11 @@ use ebur128::{EbuR128, Mode as Ebur128Mode};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::core::units::Time;
use tauri::Manager;
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
@@ -69,6 +70,14 @@ pub fn seed_from_bytes_execute(
sink.record_content_hash(server_id, track_id, &md5_16kb);
}
}
if !server_id.is_empty() {
let _ = crate::track_enrichment::run_track_enrichment_if_needed(
app,
server_id,
track_id,
bytes,
);
}
Ok(outcome)
}
@@ -94,24 +103,22 @@ pub fn seed_from_bytes_into_cache(
track_id: track_id.to_string(),
md5_16kb: md5_first_16kb(bytes),
};
if let Some(existing) = cache.get_waveform(&key)? {
if !existing.bins.is_empty() {
if cache.loudness_row_exists_for_key(&key)? {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} bins_len={} elapsed_ms={}",
track_id,
key.md5_16kb,
existing.bins.len(),
started.elapsed().as_millis()
);
return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone()));
}
crate::app_deprintln!(
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb
);
}
let coverage = cache.content_cache_coverage(server_id, track_id, &key.md5_16kb)?;
if coverage.complete() {
crate::app_deprintln!(
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} elapsed_ms={}",
track_id,
key.md5_16kb,
started.elapsed().as_millis()
);
return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone()));
}
if coverage.has_waveform && !coverage.has_loudness {
crate::app_deprintln!(
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
track_id,
key.md5_16kb
);
}
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
crate::app_deprintln!(
@@ -195,7 +202,7 @@ pub fn seed_from_bytes_into_cache(
}
}
fn md5_first_16kb(bytes: &[u8]) -> String {
pub fn md5_first_16kb(bytes: &[u8]) -> String {
let n = bytes.len().min(16 * 1024);
format!("{:x}", md5::compute(&bytes[..n]))
}
@@ -529,6 +536,169 @@ fn decode_scan_pcm(
Some(PcmScanResult { bins, loudness })
}
/// PCM window for short MIR-style analysis (typically 60 s from track center).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PcmAnalysisWindow {
pub start_sec: f64,
pub duration_sec: f64,
}
/// Pick a centered analysis window, or the full track when shorter than `window_sec`.
pub fn analysis_pcm_window(total_duration_sec: f64, window_sec: f64) -> PcmAnalysisWindow {
let total = total_duration_sec.max(0.0);
let window = window_sec.max(0.1);
if total <= window || !total.is_finite() {
return PcmAnalysisWindow {
start_sec: 0.0,
duration_sec: if total > 0.0 { total } else { window },
};
}
let start = ((total - window) / 2.0).max(0.0);
PcmAnalysisWindow {
start_sec: start,
duration_sec: window,
}
}
/// Best-effort container duration from codec metadata (seconds).
pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
let session = open_decode_session(bytes)?;
let sample_rate = session
.format
.default_track()
.or_else(|| session.format.tracks().first())
.and_then(|t| t.codec_params.sample_rate)
.filter(|&sr| sr > 0)?;
let frames = session.timeline_hint?;
Some(frames as f64 / sample_rate as f64)
}
/// Decode mono PCM for a time window. Seeks when `start_sec > 0`.
pub fn decode_mono_pcm_window(
bytes: &[u8],
start_sec: f64,
window_sec: f64,
) -> Result<(Vec<f32>, f32), String> {
if bytes.is_empty() {
return Err("empty audio buffer".to_string());
}
let DecodeSession {
mut format,
mut decoder,
track_id,
..
} = open_decode_session(bytes).ok_or_else(|| "failed to open audio decode session".to_string())?;
if start_sec.is_finite() && start_sec > 0.0 {
let time: Time = start_sec.max(0.0).into();
format
.seek(
SeekMode::Accurate,
SeekTo::Time {
time,
track_id: Some(track_id),
},
)
.map_err(|e| format!("pcm window seek failed: {e}"))?;
}
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, Some(window_sec))
}
/// Decode audio bytes to mono f32 PCM, optionally capped at `max_seconds`.
pub fn decode_mono_pcm_limited(
bytes: &[u8],
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
if bytes.is_empty() {
return Err("empty audio buffer".to_string());
}
let DecodeSession {
mut format,
mut decoder,
track_id,
..
} = open_decode_session(bytes).ok_or_else(|| "failed to open audio decode session".to_string())?;
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, max_seconds)
}
fn decode_mono_pcm_from_session(
format: &mut Box<dyn FormatReader>,
decoder: &mut Box<dyn Decoder>,
track_id: u32,
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
let mut mono = Vec::new();
let mut sample_rate = 0_f32;
let mut max_frames: Option<u64> = None;
let mut loop_i: u32 = 0;
while let Ok(packet) = format.next_packet() {
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;
}
if sample_rate <= 0.0 {
sample_rate = spec.rate as f32;
if sample_rate <= 0.0 {
return Err("invalid sample rate".to_string());
}
max_frames = max_seconds.and_then(|sec| {
if sec.is_finite() && sec > 0.0 {
Some((sec * sample_rate as f64).max(1.0) as u64)
} else {
None
}
});
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
let frames = slice.len() / n_ch;
for f in 0..frames {
if let Some(limit) = max_frames {
if mono.len() as u64 >= limit {
break;
}
}
let base = f * n_ch;
let mut acc = 0.0_f32;
for c in 0..n_ch {
acc += slice[base + c];
}
mono.push(acc / (n_ch as f32));
}
if max_frames.is_some_and(|limit| mono.len() as u64 >= limit) {
break;
}
loop_i = loop_i.wrapping_add(1);
if loop_i.is_multiple_of(128) {
std::thread::yield_now();
}
}
if mono.is_empty() {
return Err("no PCM frames decoded".to_string());
}
Ok((mono, sample_rate))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -561,6 +731,20 @@ mod tests {
assert_eq!(huge_down, -24.0);
}
#[test]
fn analysis_pcm_window_uses_center_for_long_tracks() {
let w = analysis_pcm_window(180.0, 60.0);
assert!((w.start_sec - 60.0).abs() < 1e-9);
assert!((w.duration_sec - 60.0).abs() < 1e-9);
}
#[test]
fn analysis_pcm_window_uses_full_track_when_short() {
let w = analysis_pcm_window(45.0, 60.0);
assert_eq!(w.start_sec, 0.0);
assert!((w.duration_sec - 45.0).abs() < 1e-9);
}
// ── md5_first_16kb ────────────────────────────────────────────────────────
#[test]
@@ -2,7 +2,8 @@ mod compute;
mod store;
pub use compute::{
recommended_gain_for_target, seed_from_bytes_execute, seed_from_bytes_into_cache,
SeedFromBytesOutcome,
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_limited,
decode_mono_pcm_window, md5_first_16kb, recommended_gain_for_target,
seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome,
};
pub use store::{AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
@@ -39,6 +39,20 @@ pub struct TrackKey {
pub md5_16kb: String,
}
/// Waveform / loudness rows present for a specific content fingerprint
/// (`md5_16kb`), after track-id variant + legacy server fallback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentCacheCoverage {
pub has_waveform: bool,
pub has_loudness: bool,
}
impl ContentCacheCoverage {
pub fn complete(self) -> bool {
self.has_waveform && self.has_loudness
}
}
#[derive(Debug, Clone)]
pub struct WaveformEntry {
pub bins: Vec<u8>,
@@ -306,6 +320,59 @@ impl AnalysisCache {
Ok(row.filter(|e| waveform_cache_blob_len_ok(&e.bins, e.bin_count)))
}
/// Lookup waveform + loudness for an exact content fingerprint, trying bare /
/// `stream:` track-id variants and the legacy `''` server pool (with lazy
/// re-tag onto `server_id` when a legacy hit occurs).
pub fn content_cache_coverage(
&self,
server_id: &str,
track_id: &str,
md5_16kb: &str,
) -> Result<ContentCacheCoverage, String> {
let mut has_waveform = false;
let mut has_loudness = false;
let mut relabel = false;
for tid in track_id_cache_variants(track_id) {
if !server_id.is_empty() {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: tid.clone(),
md5_16kb: md5_16kb.to_string(),
};
if self.get_waveform(&key)?.is_some() {
has_waveform = true;
}
if self.loudness_row_exists_for_key(&key)? {
has_loudness = true;
}
}
let legacy = TrackKey {
server_id: String::new(),
track_id: tid,
md5_16kb: md5_16kb.to_string(),
};
if self.get_waveform(&legacy)?.is_some() {
has_waveform = true;
if !server_id.is_empty() {
relabel = true;
}
}
if self.loudness_row_exists_for_key(&legacy)? {
has_loudness = true;
if !server_id.is_empty() {
relabel = true;
}
}
}
if relabel {
let _ = self.relabel_legacy_to_server(server_id, track_id);
}
Ok(ContentCacheCoverage {
has_waveform,
has_loudness,
})
}
/// True when this exact `(track_id, md5_16kb)` has a loudness row for the current algo version.
/// Used after `delete_loudness_for_track_id`: waveform may still be cached, but EBU data was removed.
pub fn loudness_row_exists_for_key(&self, key: &TrackKey) -> Result<bool, String> {
@@ -356,6 +423,25 @@ impl AnalysisCache {
Ok(None)
}
/// Latest `md5_16kb` fingerprint for `(server_id, track_id)` with legacy fallback.
pub fn get_latest_md5_16kb_for_track(
&self,
server_id: &str,
track_id: &str,
) -> Result<Option<String>, String> {
let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?;
if let Some(md5) = query_latest_md5_16kb_scoped(&conn, server_id, track_id)? {
return Ok(Some(md5));
}
if !server_id.is_empty() {
if let Some(md5) = query_latest_md5_16kb_scoped(&conn, "", track_id)? {
let _ = relabel_legacy_to_server(&conn, server_id, track_id);
return Ok(Some(md5));
}
}
Ok(None)
}
/// Both waveform and loudness rows exist for this `(server_id, track_id)`
/// (including the legacy `''` fallback) — a CPU seed from bytes/file would
/// only decode the file to immediately skip with `SkippedWaveformCacheHit`.
@@ -445,6 +531,40 @@ fn query_latest_waveform_scoped(
Ok(None)
}
fn query_latest_md5_16kb_scoped(
conn: &Connection,
server_id: &str,
track_id: &str,
) -> Result<Option<String>, String> {
const SQL: &str = r#"
SELECT w.md5_16kb
FROM waveform_cache w
JOIN analysis_track a
ON a.server_id = w.server_id
AND a.track_id = w.track_id
AND a.md5_16kb = w.md5_16kb
WHERE w.server_id = ?1
AND w.track_id = ?2
AND a.waveform_algo_version = ?3
ORDER BY w.updated_at DESC
LIMIT 1
"#;
for tid in track_id_cache_variants(track_id) {
let row: Option<String> = conn
.query_row(SQL, params![server_id, tid, WAVEFORM_ALGO_VERSION], |row| {
row.get(0)
})
.optional()
.map_err(|e| e.to_string())?;
if let Some(md5) = row {
if !md5.is_empty() {
return Ok(Some(md5));
}
}
}
Ok(None)
}
/// Server-scoped variant of the "latest loudness for this track" lookup.
fn query_latest_loudness_scoped(
conn: &Connection,
@@ -126,44 +126,125 @@ pub fn analysis_backfill_shared(app: &tauri::AppHandle) -> Arc<AnalysisBackfillS
.clone()
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Returns `Ok(true)` when
/// a loudness row exists in the cache after the seed (cache-hit short-circuits as
/// well as fresh decode hits).
use crate::track_analysis_plan::plan_track_analysis;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnqueueTrackAnalysisOutcome {
/// Waveform, LUFS, and enrichment facts are all current.
Complete,
/// Symphonia full-file decode queued (enrichment runs after seed when needed).
QueuedFullSeed,
/// Oximedia pass ran inline (waveform + LUFS already cached).
RanEnrichmentOnly,
}
/// **Single entry point** for byte-backed track analysis.
///
/// 1. Plan: waveform / LUFS gaps in analysis cache + enrichment facts in library.
/// 2. If nothing missing → no-op.
/// 3. If waveform or LUFS missing → CPU seed queue (Symphonia + EBU R128).
/// 4. Else if enrichment missing → oximedia 60 s window only.
pub async fn enqueue_track_analysis(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
high_priority: bool,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if bytes.is_empty() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(bytes);
let plan = plan_track_analysis(app, server_id, track_id, &content_hash);
if !plan.any() {
crate::app_deprintln!(
"[analysis] track complete track_id={} hash={}",
track_id,
content_hash
);
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
if plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
track_id,
content_hash,
plan.need_waveform,
plan.need_loudness,
plan.enrichment.any()
);
submit_analysis_cpu_seed(
app.clone(),
server_id.to_string(),
track_id.to_string(),
bytes.to_vec(),
high_priority,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
track_id,
content_hash
);
run_track_enrichment_from_bytes(app, server_id, track_id, bytes).await;
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Re-export for HTTP backfill gate (no bytes yet).
pub use crate::track_analysis_plan::track_analysis_needs_work;
/// Oximedia BPM/mood pass only — prefer [`enqueue_track_analysis`].
pub async fn run_track_enrichment_from_bytes(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
) {
if server_id.is_empty() {
return;
}
let app = app.clone();
let sid = server_id.to_string();
let tid = track_id.to_string();
let data = bytes.to_vec();
let _ = tokio::task::spawn_blocking(move || {
crate::track_enrichment::run_track_enrichment_if_needed(&app, &sid, &tid, &data);
})
.await;
}
/// Read a local file and run [`enqueue_track_analysis`] (hot cache, offline, spill promote).
pub async fn enqueue_track_analysis_from_file(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
file_path: &std::path::Path,
high_priority: bool,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
if bytes.is_empty() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
enqueue_track_analysis(app, server_id, track_id, &bytes, high_priority).await
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
) -> Result<bool, String> {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.cpu_seed_redundant_for_track(server_id, track_id).unwrap_or(false) {
return Ok(true);
}
}
let high = analysis_backfill_is_current_track(app, track_id);
let outcome = submit_analysis_cpu_seed(
app.clone(),
server_id.to_string(),
track_id.to_string(),
bytes.to_vec(),
high,
)
.await
.map_err(|e| {
crate::app_eprintln!("[analysis] failed to seed {}: {}", track_id, e);
e
})?;
let has_loudness = app
.try_state::<analysis_cache::AnalysisCache>()
.and_then(|cache| cache.get_latest_loudness_for_track(server_id, track_id).ok().flatten())
.is_some();
crate::app_deprintln!(
"[analysis] seed result track_id={} bytes={} has_loudness={} outcome={outcome:?}",
track_id,
bytes.len(),
has_loudness
);
Ok(has_loudness)
let outcome = enqueue_track_analysis(app, server_id, track_id, bytes, high).await?;
Ok(!matches!(outcome, EnqueueTrackAnalysisOutcome::Complete))
}
async fn analysis_backfill_download_and_seed(
@@ -12,7 +12,7 @@ use psysonic_core::ports::PlaybackQueryHandle;
use crate::analysis_cache;
use crate::analysis_runtime::{
analysis_backfill_is_current_track, analysis_backfill_shared, prune_analysis_queues,
AnalysisBackfillEnqueueKind,
track_analysis_needs_work, AnalysisBackfillEnqueueKind,
};
#[derive(serde::Serialize)]
@@ -228,12 +228,25 @@ pub fn analysis_enqueue_seed_from_url(
}
if !force {
if let Some(cache) = app.try_state::<analysis_cache::AnalysisCache>() {
if cache.get_latest_loudness_for_track(&server_id, &track_id)?.is_some() {
if cache.cpu_seed_redundant_for_track(&server_id, &track_id)? {
if server_id.is_empty() {
crate::app_deprintln!(
"[analysis] backfill skip (no server scope): {}",
track_id
);
return Ok(());
}
if !track_analysis_needs_work(&app, &server_id, &track_id)? {
crate::app_deprintln!(
"[analysis] backfill skip (analysis complete): {}",
track_id
);
return Ok(());
}
crate::app_deprintln!(
"[analysis] backfill skip (already cached): {}",
"[analysis] backfill enqueue (analysis pending) track_id={}",
track_id
);
return Ok(());
}
}
}
@@ -8,6 +8,8 @@
pub mod analysis_cache;
pub mod analysis_runtime;
pub mod commands;
pub mod track_analysis_plan;
pub mod track_enrichment;
// Re-export logging facade so submodules can write `crate::app_eprintln!()`
// the same way they did when they lived in the top crate.
@@ -0,0 +1,162 @@
//! Plan what a track still needs: waveform, LUFS, enrichment (BPM/mood), …
//!
//! All byte-backed enqueue paths should call [`crate::analysis_runtime::enqueue_track_analysis`],
//! which uses this module to decide full CPU seed vs enrichment-only vs no-op.
use psysonic_core::track_analysis::TrackAnalysisPlan;
use psysonic_core::track_enrichment::TrackEnrichmentPort;
use tauri::{AppHandle, Manager};
use crate::analysis_cache::AnalysisCache;
pub fn plan_track_analysis(
app: &AppHandle,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
enrichment,
}
}
/// Plan from the latest cached fingerprint when bytes are not available yet (HTTP backfill gate).
pub fn plan_track_analysis_from_cache(
app: &AppHandle,
server_id: &str,
track_id: &str,
) -> Result<TrackAnalysisPlan, String> {
let Some(cache) = app.try_state::<AnalysisCache>() else {
return Ok(TrackAnalysisPlan {
need_waveform: true,
need_loudness: true,
enrichment: Default::default(),
});
};
let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? else {
return Ok(TrackAnalysisPlan {
need_waveform: true,
need_loudness: true,
enrichment: Default::default(),
});
};
Ok(plan_track_analysis(app, server_id, track_id, &md5))
}
pub fn track_analysis_needs_work(
app: &AppHandle,
server_id: &str,
track_id: &str,
) -> Result<bool, String> {
Ok(plan_track_analysis_from_cache(app, server_id, track_id)?.any())
}
fn cache_gaps(
app: &AppHandle,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
cache_gaps_for_content(
app.try_state::<AnalysisCache>().as_deref(),
server_id,
track_id,
content_hash,
)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
if server_id.is_empty() {
return Default::default();
}
app.try_state::<TrackEnrichmentPort>()
.map(|port| port.plan(server_id, track_id, content_hash))
.unwrap_or_default()
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let Some(cache) = cache else {
return (true, true);
};
match cache.content_cache_coverage(server_id, track_id, content_hash) {
Ok(coverage) => (!coverage.has_waveform, !coverage.has_loudness),
Err(_) => (true, true),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analysis_cache::{LoudnessEntry, TrackKey, WaveformEntry};
fn seed_waveform_loudness(cache: &AnalysisCache, server_id: &str, track_id: &str, md5: &str) {
let key = TrackKey {
server_id: server_id.to_string(),
track_id: track_id.to_string(),
md5_16kb: md5.to_string(),
};
cache.touch_track_status(&key, "ready").unwrap();
cache
.upsert_waveform(
&key,
&WaveformEntry {
bins: vec![0u8; 1000],
bin_count: 500,
is_partial: false,
known_until_sec: 0.0,
duration_sec: 0.0,
updated_at: 1,
},
)
.unwrap();
cache
.upsert_loudness(
&key,
&LoudnessEntry {
integrated_lufs: -14.0,
true_peak: 1.0,
recommended_gain_db: 0.0,
target_lufs: -14.0,
updated_at: 1,
},
)
.unwrap();
}
#[test]
fn cache_gaps_true_when_empty() {
let cache = AnalysisCache::open_in_memory();
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
assert!(wf && ld);
}
#[test]
fn cache_gaps_false_when_fingerprint_present() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "s1", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
assert!(!wf && !ld);
}
#[test]
fn cache_gaps_finds_stream_prefix_row_for_bare_track_id() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "s1", "stream:t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc");
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
}
@@ -0,0 +1,127 @@
//! Client-side track enrichment — oximedia BPM + mood into library facts.
use oximedia_mir::{mood, tempo, MirConfig};
use psysonic_core::track_enrichment::{
TrackEnrichmentFacts, TrackEnrichmentIntFact, TrackEnrichmentOutcome, TrackEnrichmentPort,
TrackEnrichmentPlan, TrackEnrichmentRealFact,
};
use tauri::{AppHandle, Emitter, Manager};
use crate::analysis_cache::{
analysis_pcm_window, audio_duration_from_bytes, decode_mono_pcm_window, md5_first_16kb,
};
pub const ENRICHMENT_WINDOW_SEC: f64 = 60.0;
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnrichmentUpdatedPayload {
pub track_id: String,
pub server_id: String,
}
fn emit_enrichment_updated(app: &AppHandle, server_id: &str, track_id: &str) {
let _ = app.emit(
"analysis:enrichment-updated",
EnrichmentUpdatedPayload {
track_id: track_id.to_string(),
server_id: server_id.to_string(),
},
);
}
pub fn run_track_enrichment_if_needed(
app: &AppHandle,
server_id: &str,
track_id: &str,
bytes: &[u8],
) -> TrackEnrichmentOutcome {
if server_id.is_empty() {
return TrackEnrichmentOutcome::SkippedNoServer;
}
let Some(port) = app.try_state::<TrackEnrichmentPort>() else {
return TrackEnrichmentOutcome::SkippedNoPort;
};
let content_hash = md5_first_16kb(bytes);
let plan = port.plan(server_id, track_id, &content_hash);
if !plan.any() {
return TrackEnrichmentOutcome::SkippedComplete;
}
match analyze_and_store(&port, server_id, track_id, &content_hash, bytes, plan) {
Ok(()) => {
crate::app_deprintln!(
"[analysis][enrichment] applied track_id={} server_id={} hash={}",
track_id,
server_id,
content_hash
);
emit_enrichment_updated(app, server_id, track_id);
TrackEnrichmentOutcome::Applied
}
Err(e) => {
crate::app_eprintln!(
"[analysis][enrichment] failed track_id={} server_id={}: {}",
track_id,
server_id,
e
);
TrackEnrichmentOutcome::Failed
}
}
}
fn analyze_and_store(
port: &TrackEnrichmentPort,
server_id: &str,
track_id: &str,
content_hash: &str,
bytes: &[u8],
plan: TrackEnrichmentPlan,
) -> Result<(), String> {
let total_duration = audio_duration_from_bytes(bytes).unwrap_or(0.0);
let window = analysis_pcm_window(total_duration, ENRICHMENT_WINDOW_SEC);
let (mono, sample_rate) =
decode_mono_pcm_window(bytes, window.start_sec, window.duration_sec)?;
if mono.is_empty() || sample_rate <= 0.0 {
return Err("empty PCM window".to_string());
}
let config = MirConfig::default();
let mut facts = TrackEnrichmentFacts::default();
if plan.need_bpm {
let detector = tempo::TempoDetector::new(sample_rate, config.min_tempo, config.max_tempo);
let tempo = detector.detect(&mono).map_err(|e| format!("tempo: {e}"))?;
let bpm = tempo.bpm.round().clamp(20.0, 999.0) as i64;
facts.bpm = Some(TrackEnrichmentIntFact {
value: bpm,
confidence: tempo.confidence,
});
}
if plan.need_valence || plan.need_arousal || plan.need_moods {
let detector = mood::MoodDetector::new(sample_rate);
let mood = detector.detect(&mono).map_err(|e| format!("mood: {e}"))?;
let confidence = mood.intensity.clamp(0.0, 1.0);
if plan.need_valence {
facts.valence = Some(TrackEnrichmentRealFact {
value: mood.valence as f64,
confidence,
});
}
if plan.need_arousal {
facts.arousal = Some(TrackEnrichmentRealFact {
value: mood.arousal as f64,
confidence,
});
}
if plan.need_moods && !mood.moods.is_empty() {
facts.moods = Some(
serde_json::to_string(&mood.moods).map_err(|e| format!("moods json: {e}"))?,
);
}
}
port.store(server_id, track_id, content_hash, &facts)
}