diff options
| author | tslil <tslil@posteo.de> | 2026-07-12 18:20:30 +0100 |
|---|---|---|
| committer | tslil <tslil@posteo.de> | 2026-07-13 19:56:12 +0100 |
| commit | e0241df8afde0483fc36cb2300b53127672486dc (patch) | |
| tree | 9fa2bec196498df5b5167b55e250bb96d56d96d5 | |
Graph basedgraph
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 536 | ||||
| -rw-r--r-- | Cargo.toml | 13 | ||||
| -rw-r--r-- | src/config.rs | 48 | ||||
| -rw-r--r-- | src/learner.rs | 161 | ||||
| -rw-r--r-- | src/main.rs | 61 | ||||
| -rw-r--r-- | src/state.rs | 133 | ||||
| -rw-r--r-- | src/trajectory.rs | 98 |
8 files changed, 1051 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a504a3f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,536 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users 0.3.5", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys", +] + +[[package]] +name = "expanduser" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e0b79235da57db6b6c2beed9af6e5de867d63a973ae3e91910ddc33ba40bc0" +dependencies = [ + "dirs", + "lazy_static", + "pwd", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "mood" +version = "0.1.0" +dependencies = [ + "derive_more", + "directories", + "expanduser", + "rand", + "ron", + "serde", + "walkdir", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pwd" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c71c0c79b9701efe4e1e4b563b2016dd4ee789eb99badcb09d61ac4b92e4a2" +dependencies = [ + "libc", + "thiserror 1.0.69", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom 0.1.16", + "redox_syscall", + "rust-argon2", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7b9fc39 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "mood" +version = "0.1.0" +edition = "2024" + +[dependencies] +derive_more = { version = "2.1.1", features = ["display"] } +directories = "6.0.0" +expanduser = "1.2.2" +rand = "0.10.2" +ron = "0.12.2" +serde = {version = "1.0.228", features = ["derive"] } +walkdir = "2.5.0" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..0be1d09 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,48 @@ +use directories::{ProjectDirs, UserDirs}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct Config { + pub music_roots: Vec<String>, + pub audio_exts: Vec<String>, + pub skip_window_secs: u64, + pub learning_rate: f32, + pub decay_rate: f32, + pub min_score_thresh: f32, + pub temperature: f32, +} + +pub fn default_state_path() -> String { + ProjectDirs::from("qualifier", "organisation", "mood") + .and_then(|pd| { + pd.config_dir() + .join("mood.ron") + .to_str() + .map(|x| x.to_string()) + }) + .unwrap_or("~/.config/mood/mood.ron".to_string()) +} + +impl Default for Config { + fn default() -> Config { + Config { + music_roots: vec![ + UserDirs::new() + .and_then(|u| { + u.audio_dir() + .and_then(|p| p.to_str().map(|x| x.to_string())) + }) + .unwrap_or("~/Music".to_string()), + ], + audio_exts: [".mp3", ".flac", ".wav", ".m4a", ".ogg", ".vorbis"] + .into_iter() + .map(String::from) + .collect(), + skip_window_secs: 60, + learning_rate: 0.1, + decay_rate: 0.001, + min_score_thresh: 1e-5, + temperature: 0.6, + } + } +} diff --git a/src/learner.rs b/src/learner.rs new file mode 100644 index 0000000..91b24fe --- /dev/null +++ b/src/learner.rs @@ -0,0 +1,161 @@ +use std::collections::{HashMap, HashSet}; + +use rand::{self, seq::IndexedRandom}; +use serde::{Deserialize, Serialize}; + +use crate::{config::Config, trajectory::Action}; + +fn key(a: &str, b: &str) -> (String, String) { + let a = a.to_string(); + let b = b.to_string(); + if a < b { (a, b) } else { (b, a) } +} + +fn compute_sum( + cand: &str, + known_keys: &HashSet<&String>, + map: &HashMap<(String, String), f32>, +) -> f32 { + known_keys + .iter() + .filter_map(|k| map.get(&key(k, cand))) + .sum() +} + +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, Default)] +pub struct Learner { + similar: HashMap<(String, String), f32>, + different: HashMap<(String, String), f32>, +} + +#[derive(Debug)] +pub enum Learning { + SkipExtend, + MoreExtend(Vec<String>, String), + SkipToMore(Vec<String>, String), + MoreToSkip(Vec<String>, String), +} + +impl Learner { + pub fn prune(&mut self, valid: &HashSet<String>) { + self.similar + .retain(|(a, b), _v| valid.contains(a) && valid.contains(b)); + self.different + .retain(|(a, b), _v| valid.contains(a) && valid.contains(b)); + } + + pub fn learn(&mut self, config: &Config, learning: &Learning) { + // global decay + for (_k, v) in self.similar.iter_mut().chain(self.different.iter_mut()) { + *v *= 1.0 - config.decay_rate; + } + // update + match learning { + Learning::SkipExtend => { + // This is the least information carrying case, it does not + // follow that the trajectory contains similar or different + // items, we may simply be seeking something in particular. + } + Learning::MoreExtend(trajectory, new) => { + // We're continuing a good run so `new` is similar to everything + // in `trajectory`, but to account for the possibilty that our + // mood has changed over the course of this streak we damp + // sub-linearly that update by a proxy of temporal distance. + for (distance, e) in trajectory.iter().rev().enumerate() { + let damp = ((distance + 1) as f32).powf(-0.5); + let v = self.similar.entry(key(e, new)).or_default(); + *v = (1.0 - config.learning_rate) * *v + config.learning_rate * damp; + } + } + Learning::SkipToMore(trajectory, new) => { + // We have learnt that `new` is different to everything in + // `trajectory`, the strongest signal we have. + for e in trajectory { + let v = self.different.entry(key(e, new)).or_default(); + *v = (1.0 - config.learning_rate) * *v + config.learning_rate; + } + } + Learning::MoreToSkip(trajectory, new) => { + // `new` could be different to everything in `trajectory`, or we + // simply changed our minds, so we have only weak evidence of + // difference. The positive coherence of trajectory was taken + // care of during MoreExtend above. + let damp = (trajectory.len() + 1) as f32; + for e in trajectory { + let v = self.different.entry(key(e, new)).or_default(); + *v = (1.0 - config.learning_rate) * *v + config.learning_rate / damp; + } + } + }; + // global threshold drop + self.similar = self + .similar + .extract_if(|_k, v| (*v).abs() > config.min_score_thresh) + .collect(); + self.different = self + .different + .extract_if(|_k, v| (*v).abs() > config.min_score_thresh) + .collect(); + } + + pub fn sample( + &self, + trajectory: &[String], + action: &Action, + candidates: &HashSet<String>, + temperature: f32, + ) -> Option<(String, f32)> { + let trajectory: HashSet<_> = trajectory.iter().collect(); + let normalisation: f32 = f32::max(trajectory.len() as f32, 1.0); + + let candidates: Vec<_> = candidates + .iter() + .filter(|c| !trajectory.contains(c)) + .collect(); + + if candidates.is_empty() { + return None; + } + + let items = candidates + .iter() + .map(|&c| { + let sim = compute_sum(c, &trajectory, &self.similar); + let dif = compute_sum(c, &trajectory, &self.different); + let score = match action { + Action::Skip => dif - sim, + Action::More => sim - dif, + }; + (c, score / normalisation) + }) + .collect::<Vec<_>>(); + + softmax_sample(&items, temperature) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..74f90c8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,61 @@ +use std::process::Command; + +mod config; +mod learner; +mod state; +mod trajectory; + +use crate::{config::default_state_path, state::State}; + +fn mpc_load_and_play(path: &str) { + _ = Command::new("mpc") + .arg("clear") + .output() + .expect("failed to execute mpc clear"); + + _ = Command::new("mpc") + .args(["add", path]) + .output() + .expect("failed to execute mpc add"); + + _ = Command::new("mpc") + .arg("play") + .output() + .expect("failed to execute mpc play"); +} + +fn notify(text: &str) { + if let Err(e) = Command::new("notify-send") + .args(["-t", "5000", "-a", "mood", &text]) + .status() + { + eprintln!("Failed to show notification: {e}"); + } +} + +fn main() { + let state = State::load(&default_state_path()); + let mut state = match state { + Err(e) => { + eprintln!("Failed to load state: {e}"); + State::default() + } + Ok(s) => s, + }; + + let ur = state.update_albums(); + println!("Update result: {ur}"); + + if let Some((next_album, prob)) = state.next() { + let text = format!("Now playing: {next_album} ({:.2}%)", prob * 100.0); + println!("{text}"); + mpc_load_and_play(&next_album); + notify(&text); + } else { + println!("No next album available."); + } + + if let Err(e) = state.try_save() { + eprintln!("Failed to save state: {e}"); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..b9a6618 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,133 @@ +use std::{collections::HashSet, fs}; + +use derive_more::Display; +use expanduser::expanduser; +use serde::{Deserialize, Serialize}; +use walkdir::WalkDir; + +use crate::{ + config::{Config, default_state_path}, + learner::Learner, + trajectory::Trajectory, +}; + +#[derive(Serialize, Deserialize, Default)] +pub struct State { + pub config: Config, + trajectory: Trajectory, + learner: Learner, + albums: HashSet<String>, +} + +impl State { + pub fn next(&mut self) -> Option<(String, f32)> { + let learning = self.trajectory.step(self.config.skip_window_secs); + if let Some(ref learning) = learning { + println!("Obtained learning: {learning:?}"); + self.learner.learn(&self.config, learning); + } + if let Some((new_album, prob)) = self.learner.sample( + self.trajectory.slice(), + &learning.as_ref().into(), + &self.albums, + self.config.temperature, + ) { + self.trajectory.log(&new_album); + Some((new_album, prob)) + } else { + None + } + } +} + +#[derive(Display)] +#[display("+{added}/-{removed}, total {new_total}")] +pub struct UpdateResult { + added: usize, + removed: usize, + new_total: usize, +} + +impl State { + pub fn load(path: &str) -> Result<State, String> { + expanduser(path) + .map_err(|e| e.to_string()) + .and_then(|p| fs::read_to_string(p).map_err(|e| e.to_string())) + .and_then(|contents| ron::from_str(&contents).map_err(|e| e.to_string())) + } + + pub fn try_save(&self) -> Result<(), String> { + let str_path = default_state_path(); + let path = expanduser(&str_path).map_err(|e| e.to_string())?; + _ = path.parent().map(|parent| std::fs::create_dir_all(parent)); + let serialised = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .map_err(|e| format!("Failed to serialize state: {}", e))?; + fs::write(&path, serialised) + .map_err(|e| format!("Failed to write state file at {}: {}", str_path, e))?; + Ok(()) + } + + pub fn update_albums(&mut self) -> UpdateResult { + let roots: Vec<_> = self + .config + .music_roots + .iter() + .filter_map(|x| expanduser(x).ok().filter(|p| p.is_dir())) + .collect(); + + let mut new_albums: HashSet<String> = HashSet::new(); + + for root_path in roots { + for entry in WalkDir::new(&root_path) { + let entry = match entry { + Ok(e) if e.path().is_dir() => e, + _ => continue, + }; + + if !self.contains_audio(&entry) { + continue; + } + + if let Some(rel_str) = entry + .path() + .strip_prefix(&root_path) + .ok() + .and_then(|p| p.to_str()) + { + new_albums.insert(rel_str.to_string()); + } + } + } + + let added = new_albums.difference(&self.albums).count(); + let removed = self.albums.difference(&new_albums).count(); + self.albums.clone_from(&new_albums); + + self.trajectory.prune(&self.albums); + self.learner.prune(&self.albums); + + UpdateResult { + added, + removed, + new_total: self.albums.len(), + } + } + + fn contains_audio(&self, entry: &walkdir::DirEntry) -> bool { + fs::read_dir(entry.path()).ok().is_some_and(|mut entries| { + entries.any(|e| { + e.as_ref() + .is_ok_and(|entry| self.is_audio_file(&entry.path())) + }) + }) + } + + fn is_audio_file(&self, path: &std::path::Path) -> bool { + !path.is_dir() + && path + .extension() + .and_then(|ext| ext.to_str()) + .map(|s| s.to_lowercase()) + .is_some_and(|ext| self.config.audio_exts.contains(&format!(".{ext}"))) + } +} diff --git a/src/trajectory.rs b/src/trajectory.rs new file mode 100644 index 0000000..214e16c --- /dev/null +++ b/src/trajectory.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, time::SystemTime}; + +use crate::learner::Learning; + +#[derive(Serialize, Deserialize, Debug)] +struct LastData { + timestamp: SystemTime, + album: String, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct Trajectory { + last_data: Option<LastData>, + streak_kind: Option<Action>, + history: Vec<String>, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub enum Action { + Skip, + More, +} + +impl From<Option<&Learning>> for Action { + fn from(learning: Option<&Learning>) -> Action { + if let Some(ts) = learning { + match ts { + Learning::SkipExtend | Learning::MoreToSkip(_, _) => Action::Skip, + Learning::MoreExtend(_, _) | Learning::SkipToMore(_, _) => Action::More, + } + } else { + Action::Skip + } + } +} + +impl Trajectory { + pub fn prune(&mut self, valid: &HashSet<String>) { + self.history.retain_mut(|x| valid.contains(x)); + + if let Some(ref last_data) = self.last_data + && !valid.contains(&last_data.album) + { + self.last_data = None + } + } + + pub fn slice(&self) -> &[String] { + &self.history + } + + pub fn log(&mut self, new_album: &str) { + self.last_data = Some(LastData { + timestamp: SystemTime::now(), + album: new_album.to_string(), + }) + } + + pub fn step(&mut self, skip_window_secs: u64) -> Option<Learning> { + if let Some(ref last_data) = self.last_data { + let action = if SystemTime::now() + .duration_since(last_data.timestamp) + .ok() + .is_some_and(|d| d.as_secs() < skip_window_secs) + { + Action::Skip + } else { + Action::More + }; + + let last_album = last_data.album.clone(); + let current_streak = self.history.clone(); + + let learning = if let Some(ref kind) = self.streak_kind + && kind == &action + { + self.history.push(last_album.clone()); + match action { + Action::Skip => Learning::SkipExtend, + Action::More => Learning::MoreExtend(current_streak, last_album), + } + } else { + self.history.clear(); + self.history.push(last_album.clone()); + match action { + Action::Skip => Learning::MoreToSkip(current_streak, last_album), + Action::More => Learning::SkipToMore(current_streak, last_album), + } + }; + + self.streak_kind = Some(action); + Some(learning) + } else { + None + } + } +} |
