aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock363
-rw-r--r--Cargo.toml11
-rw-r--r--src/cli.rs60
-rw-r--r--src/config.rs47
-rw-r--r--src/learner.rs201
-rw-r--r--src/main.rs146
-rw-r--r--src/state.rs81
-rw-r--r--src/trajectory.rs39
8 files changed, 680 insertions, 268 deletions
diff --git a/Cargo.lock b/Cargo.lock
index a504a3f..6e73af8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3,6 +3,15 @@
version = 4
[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -15,18 +24,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[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"
+name = "bincode"
+version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
- "serde_core",
+ "serde",
]
[[package]]
@@ -41,6 +56,22 @@ dependencies = [
]
[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "cc"
+version = "1.2.67"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -58,19 +89,29 @@ dependencies = [
]
[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[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"
+name = "core-foundation-sys"
+version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
-dependencies = [
- "unicode-segmentation",
-]
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
@@ -88,29 +129,6 @@ 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"
@@ -154,6 +172,36 @@ dependencies = [
]
[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -188,6 +236,47 @@ dependencies = [
]
[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -209,19 +298,41 @@ dependencies = [
]
[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
name = "mood"
version = "0.1.0"
dependencies = [
- "derive_more",
+ "bincode",
+ "chrono",
"directories",
"expanduser",
"rand",
- "ron",
"serde",
+ "serde_json",
"walkdir",
]
[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -234,6 +345,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -313,20 +430,6 @@ dependencies = [
]
[[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"
@@ -339,13 +442,10 @@ dependencies = [
]
[[package]]
-name = "rustc_version"
-version = "0.4.1"
+name = "rustversion"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
-dependencies = [
- "semver",
-]
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
@@ -357,12 +457,6 @@ dependencies = [
]
[[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"
@@ -393,6 +487,31 @@ dependencies = [
]
[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -444,30 +563,12 @@ dependencies = [
]
[[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"
@@ -490,6 +591,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -521,12 +667,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -534,3 +733,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index 7b9fc39..0b3052f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,13 +1,20 @@
[package]
name = "mood"
version = "0.1.0"
+description = "A music player governed by your moods."
edition = "2024"
[dependencies]
-derive_more = { version = "2.1.1", features = ["display"] }
+bincode = "=1.3.3"
+chrono = "0.4.45"
directories = "6.0.0"
expanduser = "1.2.2"
rand = "0.10.2"
-ron = "0.12.2"
serde = {version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.150"
walkdir = "2.5.0"
+
+[profile.release]
+strip = true
+lto = "thin"
+opt-level = 3
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..f1563b3
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,60 @@
+use std::env;
+
+#[derive(Debug, PartialEq)]
+pub enum Action {
+ NewConfig,
+ NewState,
+ DumpTrajectory,
+ DumpLearner,
+ DumpAlbums,
+ Run,
+}
+
+const NAME: &str = env!("CARGO_PKG_NAME");
+const VERSION: &str = env!("CARGO_PKG_VERSION");
+const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
+
+fn print_help() {
+ println!(
+ "\
+ {NAME}: {DESCRIPTION}
+Usage: {NAME} [OPTION]
+
+Options:
+ --help Show this help message.
+ --version Show version information.
+ --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.
+ --dump-albums Dump album list from state as JSON to stdout.
+<no option> Suggest and play the next album"
+ );
+}
+
+pub fn parse() -> Option<Action> {
+ let mut args = env::args();
+
+ let _binary_name = args.next();
+
+ match args.next().as_deref() {
+ None => Some(Action::Run),
+ Some("--help") | Some("-h") => {
+ print_help();
+ None
+ }
+ Some("--version") | Some("-V") | Some("-v") => {
+ println!("{NAME} {VERSION}");
+ None
+ }
+ 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-albums") => Some(Action::DumpAlbums),
+ Some(unknown) => {
+ eprintln!("Unknown option: {unknown}");
+ std::process::exit(1);
+ }
+ }
+}
diff --git a/src/config.rs b/src/config.rs
index 0be1d09..f38b571 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,26 +1,44 @@
use directories::{ProjectDirs, UserDirs};
use serde::{Deserialize, Serialize};
+use std::fs;
+
+use expanduser::expanduser;
#[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 low_weight: f32,
+ pub mid_weight: f32,
+ pub high_weight: f32,
+ pub max_weight: f32,
}
-pub fn default_state_path() -> String {
+fn default_config_path() -> String {
+ let name = "mood.json";
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())
+ .and_then(|pd| pd.config_dir().join(name).to_str().map(|x| x.to_string()))
+ .unwrap_or(format!("~/.config/mood/{name}"))
+}
+
+impl Config {
+ pub fn try_load() -> Result<Config, String> {
+ expanduser(default_config_path())
+ .map_err(|e| e.to_string())
+ .and_then(|p| fs::read_to_string(p).map_err(|e| e.to_string()))
+ .and_then(|contents| serde_json::from_str(&contents).map_err(|e| e.to_string()))
+ }
+
+ pub fn try_save(&self) -> Result<(), String> {
+ let path = expanduser(default_config_path()).map_err(|e| e.to_string())?;
+ _ = path.parent().map(std::fs::create_dir_all);
+ let serialised = serde_json::to_string_pretty(self)
+ .map_err(|e| format!("Failed to serialize config: {}", e))?;
+ fs::write(&path, serialised).map_err(|e| format!("Failed to write config file: {}", e))?;
+ Ok(())
+ }
}
impl Default for Config {
@@ -39,10 +57,11 @@ impl Default for Config {
.map(String::from)
.collect(),
skip_window_secs: 60,
- learning_rate: 0.1,
- decay_rate: 0.001,
- min_score_thresh: 1e-5,
temperature: 0.6,
+ low_weight: 0.25,
+ mid_weight: 0.5,
+ high_weight: 0.75,
+ max_weight: 1.0,
}
}
}
diff --git a/src/learner.rs b/src/learner.rs
index 91b24fe..59db567 100644
--- a/src/learner.rs
+++ b/src/learner.rs
@@ -5,24 +5,15 @@ 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 compute_weight(past: &HashSet<String>, 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)> {
+fn softmax_sample(items: &[(String, f32)], temperature: f32) -> Option<(String, f32)> {
if items.is_empty() {
return None;
}
@@ -35,7 +26,7 @@ fn softmax_sample(items: &[(&String, f32)], temperature: f32) -> Option<(String,
let exp_values: Vec<_> = items
.iter()
- .map(|&(k, val)| (k, ((val - max_val) / temperature).exp()))
+ .map(|(k, val)| (k, ((val - max_val) / temperature).exp()))
.collect();
let sum: f32 = exp_values.iter().map(|(_, v)| *v).sum();
@@ -48,80 +39,79 @@ fn softmax_sample(items: &[(&String, f32)], temperature: f32) -> Option<(String,
.ok()
}
+#[derive(Serialize, Deserialize)]
+enum Episode {
+ Continue {
+ group: HashSet<String>,
+ avoid: Option<String>,
+ committed: String,
+ },
+ Escape {
+ from: HashSet<String>,
+ to: String,
+ committed: String,
+ },
+}
+
+impl Episode {
+ fn prune_mut(&mut self, valid: &HashSet<String>) -> bool {
+ match self {
+ Episode::Continue { group, avoid, .. } => {
+ if let Some(a) = avoid
+ && valid.contains(a)
+ {
+ } else {
+ *avoid = None;
+ };
+ group.retain(|g| valid.contains(g));
+ !group.is_empty()
+ }
+ Episode::Escape { from, to, .. } => {
+ if !valid.contains(to) {
+ return false;
+ };
+ from.retain(|f| valid.contains(f));
+ !from.is_empty()
+ }
+ }
+ }
+}
+
#[derive(Serialize, Deserialize, Default)]
pub struct Learner {
- similar: HashMap<(String, String), f32>,
- different: HashMap<(String, String), f32>,
+ history: Vec<Episode>,
}
-#[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));
+ self.history.retain_mut(|e| e.prune_mut(valid));
}
+}
- 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
+impl Learner {
+ pub fn learn(&mut self, learning: &Learning) {
+ let stamp = chrono::Utc::now().format("U%Y%m%d-%H%M%S").to_string();
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;
- }
+ self.history.push(Episode::Escape {
+ from: trajectory.iter().map(String::clone).collect(),
+ to: new.clone(),
+ committed: stamp,
+ });
}
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;
- }
+ self.history.push(Episode::Continue {
+ group: trajectory.iter().map(String::clone).collect(),
+ avoid: Some(new.clone()),
+ committed: stamp,
+ });
}
};
- // 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(
@@ -129,10 +119,9 @@ impl Learner {
trajectory: &[String],
action: &Action,
candidates: &HashSet<String>,
- temperature: f32,
+ config: &Config,
) -> 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()
@@ -143,19 +132,57 @@ impl Learner {
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<_>>();
+ let mut items: HashMap<String, f32> =
+ candidates.into_iter().map(|c| (c.clone(), 0.0)).collect();
+
+ for episode in self.history.iter() {
+ 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;
+ }
+ }
+ }
+ }
+ }
- softmax_sample(&items, temperature)
+ let pairs: Vec<_> = items.into_iter().collect();
+ softmax_sample(&pairs, config.temperature)
}
}
diff --git a/src/main.rs b/src/main.rs
index 74f90c8..86c6341 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,61 +1,135 @@
-use std::process::Command;
+use std::{process::Command, process::exit};
+mod cli;
mod config;
mod learner;
mod state;
mod trajectory;
-use crate::{config::default_state_path, state::State};
+use crate::cli::{Action, parse};
+use crate::config::Config;
+use crate::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");
+ _ = Command::new("mpc").arg("clear").output();
+
+ _ = Command::new("mpc").args(["add", path]).output();
+
+ _ = Command::new("mpc").arg("play").output();
}
fn notify(text: &str) {
if let Err(e) = Command::new("notify-send")
- .args(["-t", "5000", "-a", "mood", &text])
+ .args(["-t", "5000", "-a", "mood", text])
.status()
{
eprintln!("Failed to show notification: {e}");
}
}
+fn load_state_no_default() -> State {
+ State::try_load().unwrap_or_else(|e| {
+ eprintln!("Failed to load state: {e}, nothing to dump.");
+ exit(1);
+ })
+}
+
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 action = parse();
+ if action.is_none() {
+ return;
};
- let ur = state.update_albums();
- println!("Update result: {ur}");
+ let action = action.unwrap();
- 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.");
- }
+ match action {
+ Action::DumpAlbums => {
+ let state = load_state_no_default();
+ println!(
+ "{}",
+ state.dump_albums().unwrap_or_else(|e| {
+ eprintln!("Failed to serialize state (dump-albums): {e}");
+ exit(1);
+ })
+ );
+ }
+
+ Action::DumpLearner => {
+ let state = load_state_no_default();
+ println!(
+ "{}",
+ state.dump_learner().unwrap_or_else(|e| {
+ eprintln!("Failed to serialize state (dump-learner): {e}");
+ exit(1);
+ })
+ );
+ }
+
+ Action::DumpTrajectory => {
+ let state = load_state_no_default();
+ println!(
+ "{}",
+ state.dump_trajectory().unwrap_or_else(|e| {
+ eprintln!("Failed to serialize state (dump-trajectory): {e}");
+ exit(1);
+ })
+ );
+ }
+
+ Action::NewState => {
+ let default = State::default();
+ if let Err(e) = default.try_save() {
+ eprintln!("Failed to write new state: {e}");
+ exit(1);
+ }
+ println!("New state written.");
+ }
+
+ Action::NewConfig => {
+ let default = Config::default();
+ if let Err(e) = default.try_save() {
+ eprintln!("Failed to write new config: {e}");
+ exit(1);
+ }
+ println!("New config written.");
+ }
- if let Err(e) = state.try_save() {
- eprintln!("Failed to save state: {e}");
+ Action::Run => {
+ let config = match Config::try_load() {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("Failed to load config: {e}, writing defaults.");
+ let default = Config::default();
+ if let Err(se) = default.try_save() {
+ panic!("Failed to save default config: {se}");
+ }
+ default
+ }
+ };
+
+ let mut state = match State::try_load() {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("Failed to load state: {e}");
+ State::default()
+ }
+ };
+
+ let ur = state.update_albums(&config);
+ println!("Update result: {ur}");
+
+ if let Some((next_album, prob)) = state.next(&config) {
+ 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() {
+ panic!("Failed to save state: {e}");
+ }
+ }
}
}
diff --git a/src/state.rs b/src/state.rs
index b9a6618..a04412f 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -1,36 +1,36 @@
-use std::{collections::HashSet, fs};
-
-use derive_more::Display;
+use std::{collections::HashSet, fmt, fs};
+use directories::ProjectDirs;
use expanduser::expanduser;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
-use crate::{
- config::{Config, default_state_path},
- learner::Learner,
- trajectory::Trajectory,
-};
+use crate::{config::Config, learner::Learner, trajectory::Trajectory};
+
+fn default_state_path() -> String {
+ let name = "mood.bin";
+ ProjectDirs::from("qualifier", "organisation", "mood")
+ .and_then(|pd| pd.config_dir().join(name).to_str().map(|x| x.to_string()))
+ .unwrap_or(format!("~/.config/mood/{name}"))
+}
#[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);
+ pub fn next(&mut self, config: &Config) -> Option<(String, f32)> {
+ let learning = self.trajectory.step(config.skip_window_secs);
if let Some(ref learning) = learning {
- println!("Obtained learning: {learning:?}");
- self.learner.learn(&self.config, learning);
+ self.learner.learn(learning);
}
if let Some((new_album, prob)) = self.learner.sample(
self.trajectory.slice(),
&learning.as_ref().into(),
&self.albums,
- self.config.temperature,
+ config,
) {
self.trajectory.log(&new_album);
Some((new_album, prob))
@@ -40,36 +40,39 @@ impl State {
}
}
-#[derive(Display)]
-#[display("+{added}/-{removed}, total {new_total}")]
pub struct UpdateResult {
added: usize,
removed: usize,
new_total: usize,
}
+impl fmt::Display for UpdateResult {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "+{}/-{}, total {}", self.added, self.removed, self.new_total)
+ }
+}
+
impl State {
- pub fn load(path: &str) -> Result<State, String> {
- expanduser(path)
+ pub fn try_load() -> Result<State, String> {
+ expanduser(default_state_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()))
+ .and_then(|p| fs::read(p).map_err(|e| e.to_string()))
+ .and_then(|contents| bincode::deserialize(&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))?;
+ _ = path.parent().map(std::fs::create_dir_all);
+ let serialised =
+ bincode::serialize(self).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
+ pub fn update_albums(&mut self, config: &Config) -> UpdateResult {
+ let roots: Vec<_> = config
.music_roots
.iter()
.filter_map(|x| expanduser(x).ok().filter(|p| p.is_dir()))
@@ -77,14 +80,14 @@ impl State {
let mut new_albums: HashSet<String> = HashSet::new();
- for root_path in roots {
+ 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) {
+ if !Self::contains_audio(&entry, config) {
continue;
}
@@ -113,21 +116,35 @@ impl State {
}
}
- fn contains_audio(&self, entry: &walkdir::DirEntry) -> bool {
+ 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| self.is_audio_file(&entry.path()))
+ .is_ok_and(|entry| Self::is_audio_file(&entry.path(), config))
})
})
}
- fn is_audio_file(&self, path: &std::path::Path) -> bool {
+ fn is_audio_file(path: &std::path::Path, config: &Config) -> 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}")))
+ .is_some_and(|ext| config.audio_exts.contains(&format!(".{ext}")))
+ }
+}
+
+impl State {
+ pub fn dump_albums(&self) -> Result<String, String> {
+ serde_json::to_string_pretty(&self.albums).map_err(|e| e.to_string())
+ }
+
+ pub fn dump_learner(&self) -> Result<String, String> {
+ serde_json::to_string_pretty(&self.learner).map_err(|e| e.to_string())
+ }
+
+ pub fn dump_trajectory(&self) -> Result<String, String> {
+ serde_json::to_string_pretty(&self.trajectory).map_err(|e| e.to_string())
}
}
diff --git a/src/trajectory.rs b/src/trajectory.rs
index 214e16c..cbb0661 100644
--- a/src/trajectory.rs
+++ b/src/trajectory.rs
@@ -3,7 +3,7 @@ use std::{collections::HashSet, time::SystemTime};
use crate::learner::Learning;
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize)]
struct LastData {
timestamp: SystemTime,
album: String,
@@ -16,7 +16,7 @@ pub struct Trajectory {
history: Vec<String>,
}
-#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
+#[derive(Serialize, Deserialize, Clone, PartialEq)]
pub enum Action {
Skip,
More,
@@ -26,8 +26,8 @@ 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,
+ Learning::MoreToSkip(_, _) => Action::Skip,
+ Learning::SkipToMore(_, _) => Action::More,
}
} else {
Action::Skip
@@ -71,26 +71,29 @@ impl Trajectory {
let last_album = last_data.album.clone();
let current_streak = self.history.clone();
+ let learnt_nothing = self
+ .streak_kind
+ .as_ref()
+ .map(|kind| *kind == action)
+ .unwrap_or(false);
- 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),
+ let learning = match (learnt_nothing, &action) {
+ (false, Action::Skip) => {
+ Some(Learning::MoreToSkip(current_streak, last_album.clone()))
}
- } 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),
+ (false, Action::More) => {
+ Some(Learning::SkipToMore(current_streak, last_album.clone()))
}
+ _ => None,
+ };
+
+ if !learnt_nothing {
+ self.history.clear();
};
+ self.history.push(last_album);
self.streak_kind = Some(action);
- Some(learning)
+ learning
} else {
None
}