mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user