From fc5789e026473ea82237be847672ecb350e64479 Mon Sep 17 00:00:00 2001 From: tslil Date: Tue, 14 Jul 2026 20:22:56 +0100 Subject: license, simplify code, add README, lose "learner" naming --- src/albums.rs | 52 +++++++----------- src/cli.rs | 8 +-- src/config.rs | 13 +++-- src/intuition.rs | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/learner.rs | 158 ------------------------------------------------------ src/main.rs | 27 ++++++++-- src/state.rs | 24 +++++---- src/trajectory.rs | 2 +- 8 files changed, 225 insertions(+), 215 deletions(-) create mode 100644 src/intuition.rs delete mode 100644 src/learner.rs (limited to 'src') diff --git a/src/albums.rs b/src/albums.rs index 17966ff..c03e953 100644 --- a/src/albums.rs +++ b/src/albums.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use std::fs; +use std::path::PathBuf; use expanduser::expanduser; use walkdir::WalkDir; @@ -12,45 +12,31 @@ pub fn load(config: &Config) -> HashSet { return HashSet::new(); }; - let mut new_albums: HashSet = HashSet::new(); + let mut album_dirs: HashSet = HashSet::new(); for entry in WalkDir::new(&root_path) { - let entry = match entry { - Ok(e) if e.path().is_dir() => e, - _ => continue, - }; + let Ok(entry) = entry else { continue }; - if !contains_audio(&entry, config) { + let path = entry.path(); + if path.is_dir() { continue; - } + }; + + let is_audio_file = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| config.audio_exts.contains(&ext.to_lowercase())); - if let Some(rel_str) = entry - .path() - .strip_prefix(&root_path) - .ok() - .and_then(|p| p.to_str()) + if is_audio_file + && let Some(parent) = path.parent() + && parent != root_path { - new_albums.insert(rel_str.to_string()); + album_dirs.insert(parent.to_path_buf()); } } - new_albums -} - -fn contains_audio(entry: &walkdir::DirEntry, config: &Config) -> bool { - fs::read_dir(entry.path()).ok().is_some_and(|mut entries| { - entries.any(|e| { - e.as_ref() - .is_ok_and(|entry| is_audio_file(&entry.path(), config)) - }) - }) -} - -fn is_audio_file(path: &std::path::Path, config: &Config) -> bool { - !path.is_dir() - && path - .extension() - .and_then(|ext| ext.to_str()) - .map(str::to_lowercase) - .is_some_and(|ext| config.audio_exts.contains(&format!(".{ext}"))) + album_dirs + .iter() + .filter_map(|p| p.strip_prefix(&root_path).ok()?.to_str().map(String::from)) + .collect() } diff --git a/src/cli.rs b/src/cli.rs index 7be3fd4..b408dae 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ pub enum Action { NewConfig, NewState, DumpTrajectory, - DumpLearner, + DumpIntuition, Run, } @@ -25,8 +25,8 @@ Options: --new-config Write a default config file and exit. --new-state Write a default state file and exit. --dump-trajectory Dump the play trajectory as JSON to stdout. - --dump-learner Dump learner weights as JSON to stdout. - Suggest and play the next album" + --dump-intuition Dump intuition data as JSON to stdout. + Suggest and play the next album" ); } @@ -48,7 +48,7 @@ pub fn parse() -> Option { Some("--new-config") => Some(Action::NewConfig), Some("--new-state") => Some(Action::NewState), Some("--dump-trajectory") => Some(Action::DumpTrajectory), - Some("--dump-learner") => Some(Action::DumpLearner), + Some("--dump-intuition") => Some(Action::DumpIntuition), Some(unknown) => { eprintln!("Unknown option: {unknown}"); std::process::exit(1); diff --git a/src/config.rs b/src/config.rs index 15b449a..67721b4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,13 @@ use directories::{ProjectDirs, UserDirs}; use serde::{Deserialize, Serialize}; -use std::fs; +use std::{collections::HashSet, fs}; use expanduser::expanduser; #[derive(Serialize, Deserialize)] pub struct Config { pub music_root: String, - pub audio_exts: Vec, + pub audio_exts: HashSet, pub skip_window_secs: u64, pub temperature: f32, pub low_weight: f32, @@ -19,7 +19,12 @@ pub struct Config { fn default_config_path() -> String { let name = "mood.json"; ProjectDirs::from("qualifier", "organisation", "mood") - .and_then(|pd| pd.config_dir().join(name).to_str().map(std::string::ToString::to_string)) + .and_then(|pd| { + pd.config_dir() + .join(name) + .to_str() + .map(std::string::ToString::to_string) + }) .unwrap_or(format!("~/.config/mood/{name}")) } @@ -50,7 +55,7 @@ impl Default for Config { .and_then(|p| p.to_str().map(std::string::ToString::to_string)) }) .unwrap_or("~/Music".to_string()), - audio_exts: [".mp3", ".flac", ".wav", ".m4a", ".ogg", ".vorbis"] + audio_exts: ["mp3", "flac", "wav", "m4a", "ogg", "vorbis", "mp4"] .into_iter() .map(String::from) .collect(), diff --git a/src/intuition.rs b/src/intuition.rs new file mode 100644 index 0000000..f994029 --- /dev/null +++ b/src/intuition.rs @@ -0,0 +1,156 @@ +use std::collections::{HashMap, HashSet}; + +use rand::{self, seq::IndexedRandom}; +use serde::{Deserialize, Serialize}; + +use crate::{config::Config, trajectory::Action}; + +fn compute_weight(past: &HashSet, now: &HashSet<&String>) -> f32 { + let inter = now.iter().filter(|&&p| past.contains(p)).count(); + if inter == 0 { + return 0.0; + } + (inter as f32) / ((now.len() as f32).sqrt() * (past.len() as f32).sqrt()) +} + +fn softmax_sample(items: &[(String, f32)], temperature: f32) -> Option<(String, f32)> { + if items.is_empty() { + return None; + } + let mut rng = rand::rng(); + + let max_val = items + .iter() + .map(|(_, v)| *v) + .fold(f32::NEG_INFINITY, f32::max); + + let exp_values: Vec<_> = items + .iter() + .map(|(k, val)| (k, ((val - max_val) / temperature).exp())) + .collect(); + + let sum: f32 = exp_values.iter().map(|(_, v)| *v).sum(); + + let norm_values: Vec<_> = exp_values.iter().map(|&(k, v)| (k, v / sum)).collect(); + + norm_values + .choose_weighted(&mut rng, |item| item.1) + .map(|p| (p.0.clone(), p.1)) + .ok() +} + +#[derive(Serialize, Deserialize)] +enum Episode { + Continue { + group: HashSet, + avoid: String, + committed: String, + }, + Escape { + from: HashSet, + to: String, + committed: String, + }, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct Intuition { + history: Vec, +} + +pub enum Learning { + SkipToMore(Vec, String), + MoreToSkip(Vec, String), +} + +impl Intuition { + pub fn learn(&mut self, learning: &Learning) { + let stamp = chrono::Utc::now().format("%+").to_string(); + match learning { + Learning::SkipToMore(trajectory, new) => { + self.history.push(Episode::Escape { + from: trajectory.iter().map(String::clone).collect(), + to: new.clone(), + committed: stamp, + }); + } + Learning::MoreToSkip(trajectory, new) => { + self.history.push(Episode::Continue { + group: trajectory.iter().map(String::clone).collect(), + avoid: new.clone(), + committed: stamp, + }); + } + } + } + + pub fn sample( + &self, + trajectory: &[String], + action: &Action, + candidates: &HashSet, + config: &Config, + ) -> Option<(String, f32)> { + let trajectory: HashSet<_> = trajectory.iter().collect(); + + let candidates: Vec<_> = candidates + .iter() + .filter(|c| !trajectory.contains(c)) + .collect(); + + if candidates.is_empty() { + return None; + } + + let mut items: HashMap = + candidates.into_iter().map(|c| (c.clone(), 0.0)).collect(); + + for episode in &self.history { + match (episode, action) { + (Episode::Escape { from, to, .. }, Action::Skip) => { + let w = compute_weight(from, &trajectory); + if let Some(to_w) = items.get_mut(to) { + *to_w += config.max_weight * w; + } + for f in from { + if let Some(from_weight) = items.get_mut(f) { + *from_weight -= config.mid_weight * w; + } + } + } + (Episode::Escape { from, to, .. }, Action::More) => { + let w = compute_weight(from, &trajectory); + if trajectory.contains(to) { + for f in from { + if let Some(f_w) = items.get_mut(f) { + *f_w -= config.low_weight * w; + } + } + } + } + (Episode::Continue { group, avoid, .. }, Action::More) => { + let w = compute_weight(group, &trajectory); + for g in group { + if let Some(g_w) = items.get_mut(g) { + *g_w += config.max_weight * w; + } + } + if let Some(a_w) = items.get_mut(avoid) { + *a_w -= config.low_weight * w; + } + } + (Episode::Continue { group, .. }, Action::Skip) => { + let w = compute_weight(group, &trajectory); + for g in group { + if let Some(v) = items.get_mut(g) { + *v -= config.high_weight * w; + } + } + } + } + } + + let pairs: Vec<_> = items.into_iter().collect(); + softmax_sample(&pairs, config.temperature) + } +} diff --git a/src/learner.rs b/src/learner.rs deleted file mode 100644 index 942d52e..0000000 --- a/src/learner.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use rand::{self, seq::IndexedRandom}; -use serde::{Deserialize, Serialize}; - -use crate::{config::Config, trajectory::Action}; - -fn compute_weight(past: &HashSet, now: &HashSet<&String>) -> f32 { - let inter = now.iter().filter(|&&p| past.contains(p)).count(); - if inter == 0 { - return 0.0; - } - (inter as f32) / ((now.len() as f32).sqrt() * (past.len() as f32).sqrt()) -} - -fn softmax_sample(items: &[(String, f32)], temperature: f32) -> Option<(String, f32)> { - if items.is_empty() { - return None; - } - let mut rng = rand::rng(); - - let max_val = items - .iter() - .map(|(_, v)| *v) - .fold(f32::NEG_INFINITY, f32::max); - - let exp_values: Vec<_> = items - .iter() - .map(|(k, val)| (k, ((val - max_val) / temperature).exp())) - .collect(); - - let sum: f32 = exp_values.iter().map(|(_, v)| *v).sum(); - - let norm_values: Vec<_> = exp_values.iter().map(|&(k, v)| (k, v / sum)).collect(); - - norm_values - .choose_weighted(&mut rng, |item| item.1) - .map(|p| (p.0.clone(), p.1)) - .ok() -} - -#[derive(Serialize, Deserialize)] -enum Episode { - Continue { - group: HashSet, - avoid: Option, - committed: String, - }, - Escape { - from: HashSet, - to: String, - committed: String, - }, -} - -#[derive(Serialize, Deserialize, Default)] -pub struct Learner { - history: Vec, -} - -pub enum Learning { - SkipToMore(Vec, String), - MoreToSkip(Vec, String), -} - -impl Learner { - pub fn learn(&mut self, learning: &Learning) { - let stamp = chrono::Utc::now().format("%+").to_string(); - match learning { - Learning::SkipToMore(trajectory, new) => { - self.history.push(Episode::Escape { - from: trajectory.iter().map(String::clone).collect(), - to: new.clone(), - committed: stamp, - }); - } - Learning::MoreToSkip(trajectory, new) => { - self.history.push(Episode::Continue { - group: trajectory.iter().map(String::clone).collect(), - avoid: Some(new.clone()), - committed: stamp, - }); - } - } - } - - pub fn sample( - &self, - trajectory: &[String], - action: &Action, - candidates: &HashSet, - config: &Config, - ) -> Option<(String, f32)> { - let trajectory: HashSet<_> = trajectory.iter().collect(); - - let candidates: Vec<_> = candidates - .iter() - .filter(|c| !trajectory.contains(c)) - .collect(); - - if candidates.is_empty() { - return None; - } - - let mut items: HashMap = - candidates.into_iter().map(|c| (c.clone(), 0.0)).collect(); - - for episode in &self.history { - match (episode, action) { - (Episode::Escape { from, to, .. }, Action::Skip) => { - let w = compute_weight(from, &trajectory); - if let Some(to_w) = items.get_mut(to) { - *to_w += config.max_weight * w; - } - for f in from { - if let Some(from_weight) = items.get_mut(f) { - *from_weight -= config.mid_weight * w; - } - } - } - (Episode::Escape { from, to, .. }, Action::More) => { - let w = compute_weight(from, &trajectory); - if trajectory.contains(to) { - for f in from { - if let Some(f_w) = items.get_mut(f) { - *f_w -= config.low_weight * w; - } - } - } - } - (Episode::Continue { group, avoid, .. }, Action::More) => { - let w = compute_weight(group, &trajectory); - for g in group { - if let Some(g_w) = items.get_mut(g) { - *g_w += config.max_weight * w; - } - } - if let Some(a) = avoid - && let Some(a_w) = items.get_mut(a) - { - *a_w -= config.low_weight * w; - } - } - (Episode::Continue { group, .. }, Action::Skip) => { - let w = compute_weight(group, &trajectory); - for g in group { - if let Some(v) = items.get_mut(g) { - *v -= config.high_weight * w; - } - } - } - } - } - - let pairs: Vec<_> = items.into_iter().collect(); - softmax_sample(&pairs, config.temperature) - } -} diff --git a/src/main.rs b/src/main.rs index d16b50f..2a1f2c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,26 @@ +/* mood --- A music player governed by your moods. + Copyright (C) 2026 tslil clingman + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + use std::{process::Command, process::exit}; mod albums; mod cli; mod config; -mod learner; +mod intuition; mod state; mod trajectory; @@ -44,12 +61,12 @@ fn main() { let action = action.unwrap(); match action { - Action::DumpLearner => { + Action::DumpIntuition => { let state = load_state_no_default(); println!( "{}", - state.dump_learner().unwrap_or_else(|e| { - eprintln!("Failed to serialize state (dump-learner): {e}"); + state.dump_intuition().unwrap_or_else(|e| { + eprintln!("Failed to serialise state (dump-intuition): {e}"); exit(1); }) ); @@ -60,7 +77,7 @@ fn main() { println!( "{}", state.dump_trajectory().unwrap_or_else(|e| { - eprintln!("Failed to serialize state (dump-trajectory): {e}"); + eprintln!("Failed to serialise state (dump-trajectory): {e}"); exit(1); }) ); diff --git a/src/state.rs b/src/state.rs index 195d5cf..47998f3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,19 +4,24 @@ use directories::ProjectDirs; use expanduser::expanduser; use serde::{Deserialize, Serialize}; -use crate::{albums, config::Config, learner::Learner, trajectory::Trajectory}; +use crate::{albums, config::Config, intuition::Intuition, trajectory::Trajectory}; fn default_state_path() -> String { let name = "mood.json.zstd"; ProjectDirs::from("qualifier", "organisation", "mood") - .and_then(|pd| pd.config_dir().join(name).to_str().map(std::string::ToString::to_string)) + .and_then(|pd| { + pd.config_dir() + .join(name) + .to_str() + .map(std::string::ToString::to_string) + }) .unwrap_or(format!("~/.config/mood/{name}")) } #[derive(Serialize, Deserialize, Default)] pub struct State { trajectory: Trajectory, - learner: Learner, + intuition: Intuition, } impl State { @@ -25,16 +30,15 @@ impl State { if albums.is_empty() { println!("Found no albums in {}", config.music_root); return None; - } else { - let l = albums.len(); - println!("Found {} album{}", l, if l > 1 { "s" } else { "" }); } + let l = albums.len(); + println!("Found {} album{}", l, if l > 1 { "s" } else { "" }); let learning = self.trajectory.step(config.skip_window_secs); if let Some(ref learning) = learning { - self.learner.learn(learning); + self.intuition.learn(learning); } - if let Some((new_album, prob)) = self.learner.sample( + if let Some((new_album, prob)) = self.intuition.sample( self.trajectory.slice(), &learning.as_ref().into(), &albums, @@ -77,8 +81,8 @@ impl State { } impl State { - pub fn dump_learner(&self) -> Result { - serde_json::to_string_pretty(&self.learner).map_err(|e| e.to_string()) + pub fn dump_intuition(&self) -> Result { + serde_json::to_string_pretty(&self.intuition).map_err(|e| e.to_string()) } pub fn dump_trajectory(&self) -> Result { diff --git a/src/trajectory.rs b/src/trajectory.rs index 7f41a65..04d5aab 100644 --- a/src/trajectory.rs +++ b/src/trajectory.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::time::SystemTime; -use crate::learner::Learning; +use crate::intuition::Learning; #[derive(Serialize, Deserialize)] struct LastData { -- cgit v1.2.3