Files
Psychotoxical-psysonic/src-tauri/crates/psysonic-library/src/mood_groups.rs
T
cucadmuh 003b280a77 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.
2026-05-23 18:54:04 +03:00

372 lines
11 KiB
Rust

//! Virtual mood groups and atomic mood tags for Advanced Search.
//!
//! Tracks store **atomic tags** in `track_fact` (`fact_kind = mood_tag`).
//! Product groups (joy, dance, …) are a static catalog only — each group
//! lists tag ids; search expands a group to `mood_tag IN (…)` with OR
//! semantics. Groups **may overlap** on purpose (e.g. joy and dance both
//! include `happy`). New tags can be added to the catalog without schema
//! changes.
use std::cmp::Ordering;
use std::collections::HashSet;
/// Oximedia `MoodDetector` label ids shipped today (mirrors TS catalog).
pub const OXIMEDIA_MOOD_TAG_IDS: &[&str] = &[
"happy",
"excited",
"calm",
"peaceful",
"angry",
"tense",
"sad",
"melancholic",
];
/// Product mood group ids (i18n: `search.moodGroups.*`).
pub const MOOD_GROUP_IDS: &[&str] = &["joy", "sadness", "dance", "work", "romance", "anger"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MoodGroup {
pub id: &'static str,
pub tags: &'static [&'static str],
}
/// Virtual groups → atomic tags. Overlaps are intentional.
pub const MOOD_GROUPS: &[MoodGroup] = &[
MoodGroup {
id: "joy",
tags: &["happy", "excited"],
},
MoodGroup {
id: "sadness",
tags: &["sad", "melancholic"],
},
MoodGroup {
id: "dance",
tags: &["excited", "happy", "tense", "angry"],
},
MoodGroup {
id: "work",
tags: &["calm", "peaceful"],
},
MoodGroup {
id: "romance",
tags: &["peaceful", "calm", "melancholic"],
},
MoodGroup {
id: "anger",
tags: &["angry", "tense"],
},
];
pub fn is_oximedia_mood_tag(id: &str) -> bool {
OXIMEDIA_MOOD_TAG_IDS.contains(&id)
}
pub fn is_valid_mood_group(id: &str) -> bool {
MOOD_GROUP_IDS.contains(&id)
}
pub fn lookup_mood_group(id: &str) -> Option<&'static MoodGroup> {
MOOD_GROUPS.iter().find(|g| g.id == id)
}
/// Known tag ids for filters / validation (oximedia + any catalog-only tags).
pub fn is_known_mood_tag(id: &str) -> bool {
if is_oximedia_mood_tag(id) {
return true;
}
MOOD_GROUPS.iter().any(|g| g.tags.contains(&id))
}
/// Expand virtual group ids to deduplicated atomic tag ids (stable order).
pub fn expand_mood_groups(group_ids: &[String]) -> Result<Vec<String>, String> {
if group_ids.is_empty() {
return Err("expected at least one mood group".to_string());
}
let mut out: Vec<String> = Vec::new();
for gid in group_ids {
let group = lookup_mood_group(gid)
.ok_or_else(|| format!("unknown mood group `{gid}`"))?;
for tag in group.tags {
if !out.iter().any(|t| t == tag) {
out.push((*tag).to_string());
}
}
}
Ok(out)
}
/// Validate mood-group ids for `mood_group` filters (`eq` / `in`).
pub fn normalize_mood_groups(group_ids: &[String]) -> Result<Vec<String>, String> {
if group_ids.is_empty() {
return Err("expected at least one mood group".to_string());
}
let mut out: Vec<String> = Vec::new();
for id in group_ids {
if !is_valid_mood_group(id) {
return Err(format!("unknown mood group `{id}`"));
}
if !out.iter().any(|g| g == id) {
out.push(id.clone());
}
}
Ok(out)
}
/// Valence/arousal anchor in normalized mood space (see `mood_scores_from_valence_arousal`).
struct MoodVaAnchor {
id: &'static str,
v: f64,
a: f64,
}
const MOOD_VA_ANCHORS: &[MoodVaAnchor] = &[
MoodVaAnchor { id: "happy", v: 0.75, a: 0.72 },
MoodVaAnchor { id: "excited", v: 0.55, a: 0.88 },
MoodVaAnchor { id: "calm", v: 0.65, a: 0.22 },
MoodVaAnchor { id: "peaceful", v: 0.78, a: 0.12 },
MoodVaAnchor { id: "angry", v: -0.72, a: 0.82 },
MoodVaAnchor { id: "tense", v: -0.35, a: 0.68 },
MoodVaAnchor { id: "sad", v: -0.75, a: 0.28 },
MoodVaAnchor { id: "melancholic", v: -0.55, a: 0.18 },
];
const MOOD_VA_MAX_DIST: f64 = 1.35;
const MOOD_VA_VALENCE_BIAS: f64 = 0.12;
const MOOD_VA_VALENCE_SCALE: f64 = 1.4;
const MOOD_VA_AROUSAL_OFFSET: f64 = 0.48;
const MOOD_VA_AROUSAL_SCALE: f64 = 0.40;
const MOOD_DISPLAY_MIN_RELATIVE: f64 = 0.55;
const MOOD_DISPLAY_MIN_ABSOLUTE: f64 = 0.28;
/// Pairs shown as one mood in UI/search tags — never both `happy` and `excited`.
const MOOD_DISPLAY_CLUSTERS: &[&[&str]] = &[
&["happy", "excited"],
&["calm", "peaceful"],
&["angry", "tense"],
&["sad", "melancholic"],
];
fn mood_display_cluster(tag: &str) -> Option<usize> {
MOOD_DISPLAY_CLUSTERS
.iter()
.position(|cluster| cluster.contains(&tag))
}
/// Soft scores for all oximedia mood tags from raw valence/arousal.
///
/// Oximedia's built-in `map_to_moods` uses hard quadrant cutoffs and returns
/// only two labels (usually `happy` + `excited` for typical pop/rock). We
/// recalibrate V/A and score every catalog tag by distance to anchor points.
pub fn mood_scores_from_valence_arousal(valence: f64, arousal: f64) -> Vec<(String, f64)> {
let v = ((valence - MOOD_VA_VALENCE_BIAS) * MOOD_VA_VALENCE_SCALE).clamp(-1.0, 1.0);
let a = ((arousal - MOOD_VA_AROUSAL_OFFSET) / MOOD_VA_AROUSAL_SCALE).clamp(0.0, 1.0);
MOOD_VA_ANCHORS
.iter()
.map(|anchor| {
let dv = v - anchor.v;
let da = a - anchor.a;
let dist = (dv * dv + da * da).sqrt();
let score = (1.0 - dist / MOOD_VA_MAX_DIST).max(0.0);
(anchor.id.to_string(), score)
})
.collect()
}
pub fn top_distinct_oximedia_mood_tag_ids_from_scores(
scores: &[(String, f64)],
limit: usize,
) -> Vec<String> {
let mut scored = scores.to_vec();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
scored.retain(|(k, _)| is_oximedia_mood_tag(k));
let top_score = scored.first().map(|(_, s)| *s).unwrap_or(0.0);
let mut out = Vec::new();
let mut used_clusters = HashSet::new();
for (tag, score) in scored {
if score < MOOD_DISPLAY_MIN_ABSOLUTE || score < top_score * MOOD_DISPLAY_MIN_RELATIVE {
continue;
}
if let Some(cluster) = mood_display_cluster(&tag) {
if !used_clusters.insert(cluster) {
continue;
}
}
out.push(tag);
if out.len() >= limit {
break;
}
}
out
}
pub fn top_distinct_oximedia_mood_tag_ids_from_moods_json(json: &str, limit: usize) -> Vec<String> {
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
let Some(obj) = parsed.as_object() else {
return Vec::new();
};
let scores: Vec<(String, f64)> = obj
.iter()
.filter_map(|(k, v)| v.as_f64().map(|score| (k.clone(), score)))
.collect();
top_distinct_oximedia_mood_tag_ids_from_scores(&scores, limit)
}
pub fn top_mood_tag_ids_from_valence_arousal(
valence: f64,
arousal: f64,
limit: usize,
) -> Vec<String> {
top_distinct_oximedia_mood_tag_ids_from_scores(
&mood_scores_from_valence_arousal(valence, arousal),
limit,
)
}
/// Top oximedia mood tag ids by score (filter unknown labels first, then sort
/// by score desc, id asc). Mirrors TS `topOximediaMoodTagIds`.
pub fn top_oximedia_mood_tag_ids_from_moods_json(json: &str, limit: usize) -> Vec<String> {
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
let Some(obj) = parsed.as_object() else {
return Vec::new();
};
let scores: Vec<(String, f64)> = obj
.iter()
.filter_map(|(k, v)| v.as_f64().map(|score| (k.clone(), score)))
.collect();
top_oximedia_mood_tag_ids_from_scores(&scores, limit)
}
pub fn top_oximedia_mood_tag_ids_from_scores(
scores: &[(String, f64)],
limit: usize,
) -> Vec<String> {
let mut scored = scores.to_vec();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
scored
.into_iter()
.filter(|(k, _)| is_oximedia_mood_tag(k))
.take(limit)
.map(|(k, _)| k)
.collect()
}
/// Validate atomic mood-tag ids for direct `mood_tag` filters.
pub fn normalize_mood_tags(tag_ids: &[String]) -> Result<Vec<String>, String> {
if tag_ids.is_empty() {
return Err("expected at least one mood tag".to_string());
}
let mut out: Vec<String> = Vec::new();
for id in tag_ids {
if !is_known_mood_tag(id) {
return Err(format!("unknown mood tag `{id}`"));
}
if !out.iter().any(|t| t == id) {
out.push(id.clone());
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joy_expands_to_happy_and_excited() {
assert_eq!(
expand_mood_groups(&["joy".into()]).unwrap(),
vec!["happy", "excited"]
);
}
#[test]
fn groups_overlap_by_design() {
let joy = expand_mood_groups(&["joy".into()]).unwrap();
let dance = expand_mood_groups(&["dance".into()]).unwrap();
assert!(joy.iter().any(|t| dance.contains(t)));
let work = expand_mood_groups(&["work".into()]).unwrap();
let romance = expand_mood_groups(&["romance".into()]).unwrap();
assert!(work.iter().any(|t| romance.contains(t)));
}
#[test]
fn all_oximedia_tags_appear_in_at_least_one_group() {
for tag in OXIMEDIA_MOOD_TAG_IDS {
assert!(
MOOD_GROUPS.iter().any(|g| g.tags.contains(tag)),
"oximedia tag `{tag}` must appear in a virtual group"
);
}
}
#[test]
fn anger_expands_to_q3_tags() {
assert_eq!(
expand_mood_groups(&["anger".into()]).unwrap(),
vec!["angry", "tense"]
);
}
#[test]
fn unknown_group_errors() {
assert!(expand_mood_groups(&["nope".into()]).is_err());
}
#[test]
fn top_mood_tags_ignore_unknown_labels_before_limit() {
let json = r#"{"noise":0.99,"calm":0.2,"happy":0.9,"excited":0.5}"#;
assert_eq!(
top_oximedia_mood_tag_ids_from_moods_json(json, 3),
vec!["happy", "excited", "calm"]
);
}
#[test]
fn valence_arousal_never_returns_both_happy_and_excited() {
let tags = top_mood_tag_ids_from_valence_arousal(0.4, 0.75, 2);
assert!(
!(tags.contains(&"happy".to_string()) && tags.contains(&"excited".to_string())),
"got {tags:?}"
);
assert_eq!(tags.len(), 2);
}
#[test]
fn valence_arousal_soft_scores_differ_from_quadrant_happy_excited() {
let tags = top_mood_tag_ids_from_valence_arousal(0.4, 0.75, 2);
assert_ne!(tags, vec!["happy", "excited"]);
}
#[test]
fn low_arousal_prefers_calm_or_peaceful() {
let tags = top_mood_tag_ids_from_valence_arousal(0.55, 0.42, 2);
assert!(
tags.iter().any(|t| t == "calm" || t == "peaceful"),
"got {tags:?}"
);
}
#[test]
fn negative_valence_high_arousal_prefers_anger_quadrant() {
let tags = top_mood_tag_ids_from_valence_arousal(-0.45, 0.82, 2);
assert!(
tags.iter().any(|t| t == "angry" || t == "tense"),
"got {tags:?}"
);
}
}