summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortslil <tslil@posteo.de>2026-07-12 18:20:30 +0100
committertslil <tslil@posteo.de>2026-07-13 19:56:12 +0100
commite0241df8afde0483fc36cb2300b53127672486dc (patch)
tree9fa2bec196498df5b5167b55e250bb96d56d96d5
Graph basedgraph
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock536
-rw-r--r--Cargo.toml13
-rw-r--r--src/config.rs48
-rw-r--r--src/learner.rs161
-rw-r--r--src/main.rs61
-rw-r--r--src/state.rs133
-rw-r--r--src/trajectory.rs98
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
+ }
+ }
+}