mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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]
|
||||
|
||||
Reference in New Issue
Block a user