Compare commits

..
67 Commits
Author SHA1 Message Date
tdfischer 2e4071736d shows: episode 206 in the bag 2026-06-30 09:09:26 +02:00
tdfischer 33cfe4e5fa prompts: computer: threw the prompt at chatgpt to create a more streamlined one that is still buggy and loops forever :D 2026-06-30 09:09:18 +02:00
tdfischer 9cc27da0ab artifacts: beets: for some reason beets prints ratings as strings 2026-06-30 09:08:23 +02:00
tdfischer c3a4dffff0 ui: store the last selected spoken text when not focused 2026-06-30 09:07:22 +02:00
tdfischer eee2f07857 sfx: debug-- 2026-06-30 09:05:56 +02:00
tdfischer 0e3ba36fc3 artifacts: beets: rewrite the query API to be more comprehensive and structured 2026-06-26 10:22:22 +02:00
tdfischer 0f69a5a3ad artifacts: add more metadata accessable through the mixxedbeets plugin 2026-06-26 10:21:47 +02:00
tdfischer c0e05fb2bb prediction: toolbox: add documentation hints to the task list args 2026-06-26 10:20:33 +02:00
tdfischer f4e1adfd4a artifacts: bandcamp: implement synchronization for artists 2026-06-26 10:19:10 +02:00
tdfischer f55865c4e5 prediction: computer commands should be user messages, not system 2026-06-26 10:08:17 +02:00
tdfischer 92660a86a0 character: stick archive context at the end of the messages, to take advantage of token caching 2026-06-26 10:07:46 +02:00
tdfischer 919e878d5f shows: episode 205 is in the bag 2026-06-23 09:20:10 +02:00
tdfischer 7157d4bb39 widgets: try and sort the computer todo list by completion state 2026-06-23 09:19:55 +02:00
tdfischer 0080b1239c transcription: crash-- 2026-06-23 09:19:40 +02:00
tdfischer 0d270e80b3 artifacts: mixxx: load the real mixxxdb 2026-06-23 09:19:28 +02:00
tdfischer 03d2cdf500 ui: add some sfx integration to the UI, fix a json crash 2026-06-23 09:19:11 +02:00
tdfischer 42f764c914 sfx: implement mixing multiple playbacks at once into the same output stream 2026-06-23 09:18:32 +02:00
tdfischer 646ea5bca3 sfx: start moving code into a new Player struct, which will eventually support mixing multiple audio streams 2026-06-22 21:55:47 +02:00
tdfischer bcf37182fe fix audio playback of 8Khz samples, further cut down on clones/copies 2026-06-22 16:18:52 +02:00
tdfischer 2d95d0c6aa clippy++ 2026-06-22 16:18:13 +02:00
tdfischer b7559d7fb6 scene: conversation: use Display instead of ToString for clippy++ 2026-06-22 16:17:49 +02:00
tdfischer 74a823d1c2 audio: implement random SFX output 2026-06-22 13:54:42 +02:00
tdfischer 85fa485833 main: run ratatui handler on crash 2026-06-22 13:53:42 +02:00
tdfischer 7563ab85ca clippy++ and report internal error types from tools 2026-06-22 13:53:33 +02:00
tdfischer 242771332c prediction: make mixx loading a little more efficient 2026-06-22 10:47:03 +02:00
tdfischer 7cbff539df src: drop a lot of unwraps() 2026-06-22 10:23:24 +02:00
tdfischer 70ec40a880 prediction: rewrite the prompting stack to create the ship computer as a second character, and let characters/agents operate autonomously with their own tasks and event queues 2026-06-22 08:57:49 +02:00
tdfischer 2d7153eaf7 artifacts: fix dedupe bug 2026-06-22 08:55:59 +02:00
tdfischer 99323c9683 gitignore: ignore mixxx database and out.log 2026-06-22 08:52:25 +02:00
tdfischer 89125d2def prediction: split out maintenance (and thereby the logging interface) of the UI conversation to a separate task, so log::* can work in realtime. 2026-06-17 22:16:19 +02:00
tdfischer a8a44dae63 prediction: push the bulk of the main event loop into the session impl finally 2026-06-17 20:12:58 +02:00
tdfischer cbf7cbd1dd audio: rewrite the audio stack with a more modular architecture and less code 2026-06-17 20:00:01 +02:00
tdfischer e78a2c3215 musicbrainz: dedupe some code 2026-06-17 12:04:26 +02:00
tdfischer 8716350a4e beets: make beets command async 2026-06-17 12:04:00 +02:00
tdfischer 3a8130d785 artifacts: rewrite the entire artifact querying layer to create modular 'tools' and 'datasource's 2026-06-17 11:09:50 +02:00
tdfischer 33e0b1768f cargo: update deps 2026-06-16 11:32:45 +02:00
tdfischer 6e0d986226 main: only report info level log events to the UI, everything else goes to out.log 2026-06-16 11:32:34 +02:00
tdfischer d69ba43a6b artifacts: implement musicbrainz tooling 2026-06-16 11:32:15 +02:00
tdfischer ac6cb425ac tts: increase voice pitch and speed 2026-06-16 11:31:19 +02:00
tdfischer da64a1eea3 widgets: todo++ 2026-06-16 11:31:06 +02:00
tdfischer d83d430ec2 artifacts: mixxx: crash-- 2026-06-16 11:30:45 +02:00
tdfischer 8db22b2539 audio: reimplement port reconnections using slices and less repeated code 2026-06-16 11:30:15 +02:00
tdfischer 0a2a09eb3c scene: stagedirection: switch to a generic playlist name based interface to mixxx, instead of PWM episode numbers 2026-06-16 11:29:48 +02:00
tdfischer 359941c094 scene: conversation: recolor the computer lines I guess 2026-06-16 11:27:45 +02:00
tdfischer b9af9915d1 shows: 204 in the bag 2026-06-16 11:07:45 +02:00
tdfischer 5595b02211 artifacts: split out each artifact source into a submodule, move archive.rs into artifacts/beets.rs 2026-06-15 15:27:20 +02:00
tdfischer 59a03eb72c prediction: don't inflate data with hammering bandcamp, at least until we get musicbrainz working 2026-06-15 15:19:01 +02:00
tdfischer af50abd4d3 prediction: docs++ 2026-06-15 15:18:30 +02:00
tdfischer de78737ce7 prediction: always refresh after each iteration 2026-06-15 15:18:17 +02:00
tdfischer c2c8c99d7a todo: add todo.md 2026-06-11 21:45:50 +02:00
tdfischer aaa1b48b26 prediction: switch everything over to use log::* 2026-06-11 21:43:31 +02:00
tdfischer cc56b09249 artifacts: add artifact sources 2026-06-11 21:42:26 +02:00
tdfischer a049cdacdb artifacts: make beets more forgiving, and handle errors with logs 2026-06-11 21:41:29 +02:00
tdfischer 5784c2440a main: add a separate logfile to the logger 2026-06-11 21:40:20 +02:00
tdfischer 110bc2cbbe audio: add another stream for sound effects output 2026-06-11 21:38:16 +02:00
tdfischer f269e62e34 ui: split out lots of code into a widets mod 2026-06-11 21:37:23 +02:00
tdfischer 5aa2631b99 reduce vu update frequency, report more sql errors, fix bug with startup show time 2026-06-10 00:23:13 +02:00
tdfischer b1453b3fbc ui: split out the app struct into a new ui mod 2026-06-10 00:13:13 +02:00
tdfischer cac1822734 artifacts: implement merge() 2026-06-09 23:34:30 +02:00
tdfischer 45b3ada3e9 archive: handle error from bad beets json 2026-06-09 23:34:21 +02:00
tdfischer 0396bc12e0 prediction: move actions into prediction mod 2026-06-09 23:34:07 +02:00
tdfischer 2fe1cc3d5c artifacts: rewrite the artifacts model to be source agnostic for now 2026-06-09 22:00:37 +02:00
tdfischer 44afe5a713 artifacts: move mixxx api bits into artifacts module 2026-06-09 20:29:52 +02:00
tdfischer 96ec57b2d9 src: adopt the log crate and feed logs into the UI 2026-06-09 20:24:11 +02:00
tdfischer eeb27656c7 src: split out conversation and archive bits into separate modules 2026-06-09 19:30:40 +02:00
tdfischer 7f2dd6f8b2 artifacts: move some of the artifacts code into its own module 2026-06-09 19:16:41 +02:00
tdfischer ad90df7767 main: refactor prediction engine to use an event stream 2026-06-09 19:08:08 +02:00
31 changed files with 14864 additions and 1200 deletions
+2
View File
@@ -2,3 +2,5 @@
save.json
.env
audio.json
mixxxdb.sqlite*
out.log
Generated
+661 -10
View File
@@ -89,6 +89,52 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api_bindium"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fd2b5604962dee5b3caed7623897b48afd64b47d008b0eec813473e34532cce"
dependencies = [
"async-io",
"blocking",
"bon",
"serde_core",
"serde_json",
"snafu 0.9.1",
"ureq",
"url-escape",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-openai"
version = "0.40.2"
@@ -132,6 +178,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -250,7 +302,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"snafu",
"snafu 0.8.9",
"url",
]
@@ -318,6 +370,44 @@ dependencies = [
"generic-array",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bon"
version = "3.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2f04f6fef12d70d42a77b1433c9e0f065238479a6cefc4f5bab105e9873a3c3"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "3.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d0bd4c2f75335ad98052a37efb54f428b492f64340257143b3429c8a508fa7b"
dependencies = [
"darling 0.23.0",
"ident_case",
"prettyplease",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.117",
]
[[package]]
name = "btree-range-map"
version = "0.7.2"
@@ -402,6 +492,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -503,6 +604,15 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.15.11"
@@ -530,6 +640,35 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap 2.14.0",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -565,6 +704,24 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -677,6 +834,16 @@ dependencies = [
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core 0.21.3",
"darling_macro 0.21.3",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -701,6 +868,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_core"
version = "0.23.0"
@@ -725,6 +905,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core 0.21.3",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
@@ -958,6 +1149,28 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "enumset"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d"
dependencies = [
"enumset_derive",
"serde",
]
[[package]]
name = "enumset_derive"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a"
dependencies = [
"darling 0.21.3",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -994,14 +1207,19 @@ dependencies = [
"color-eyre",
"crossterm",
"dotenv",
"enumset",
"futures",
"futures-timer",
"hound",
"iref 4.0.0",
"itertools 0.15.0",
"jack",
"json-ld",
"log",
"minify",
"musicbrainz_rs",
"oximedia-metering",
"rand 0.10.1",
"ratatui",
"rc-writer",
"rdf-types",
@@ -1013,12 +1231,37 @@ dependencies = [
"serde_json",
"sqlite",
"static-iref",
"static_cell",
"symphonia",
"tempfile",
"textwrap",
"throbber-widgets-tui",
"tokio",
"tokio-stream",
"tui-input",
"tui-skeleton",
"uuid",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
@@ -1032,6 +1275,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "eyre"
version = "0.6.12"
@@ -1087,6 +1336,16 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1183,6 +1442,16 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@@ -1284,6 +1553,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
@@ -1294,6 +1564,12 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "h2"
version = "0.4.14"
@@ -1379,6 +1655,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1788,6 +2070,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2293,9 +2584,9 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.30"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru"
@@ -2312,6 +2603,26 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lucene_query_builder"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bef2a829731f3ff5bc5534d4975a0f1eb1399d04fb41af5f6e829ed7dd2a2ef"
dependencies = [
"lucene_query_builder_rs_derive",
]
[[package]]
name = "lucene_query_builder_rs_derive"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c33cd74a8681a2ce0746a8bbf366d2117bd86609529cbf2ed5a93f31221021"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "mac_address"
version = "1.1.8"
@@ -2389,6 +2700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
@@ -2409,6 +2721,21 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7627d8bbeb17edbf1c3f74b21488e4af680040da89713b4217d0010e9cbd97e"
[[package]]
name = "musicbrainz_rs"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c55646251398a2ba9c016bc6c7453e2759377eca24c81ca815d27e9da47fc981"
dependencies = [
"api_bindium",
"bon",
"chrono",
"glob",
"lucene_query_builder",
"serde",
"snafu 0.9.1",
]
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -2692,6 +3019,12 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2900,12 +3233,37 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -3099,6 +3457,17 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
@@ -3124,6 +3493,12 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
@@ -3163,7 +3538,7 @@ dependencies = [
"compact_str",
"hashbrown 0.16.1",
"indoc",
"itertools",
"itertools 0.14.0",
"kasuari",
"lru",
"strum",
@@ -3215,7 +3590,7 @@ dependencies = [
"hashbrown 0.16.1",
"indoc",
"instability",
"itertools",
"itertools 0.14.0",
"line-clipping",
"ratatui-core",
"strum",
@@ -3335,6 +3710,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -3542,6 +3923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -3845,7 +4227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -3895,6 +4277,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.1.1"
@@ -3961,7 +4349,16 @@ version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
dependencies = [
"snafu-derive",
"snafu-derive 0.8.9",
]
[[package]]
name = "snafu"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522"
dependencies = [
"snafu-derive 0.9.1",
]
[[package]]
@@ -3976,6 +4373,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "snafu-derive"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -4085,6 +4494,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "static_cell"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23"
dependencies = [
"portable-atomic",
]
[[package]]
name = "str-newtype"
version = "2.0.0"
@@ -4170,6 +4588,179 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symphonia"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-adpcm",
"symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76"
dependencies = [
"lazy_static",
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebbdfd76d6cc5a601c6292a44357c5b7c82f2cd7cdc0f171421f5c5cff0ea1f"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-alac"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-common"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1"
dependencies = [
"bitflags 2.11.1",
"bytemuck",
"lazy_static",
"log",
"num-complex",
"rustfft",
"smallvec",
]
[[package]]
name = "symphonia-format-mkv"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb17713e134f5ad316c2690fa3104590ccc85842cdbcf82c3cd1a845cb08aa74"
dependencies = [
"lazy_static",
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-format-riff"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681"
dependencies = [
"lazy_static",
"log",
"regex-lite",
"smallvec",
"symphonia-core",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -4395,12 +4986,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
@@ -4409,6 +5002,16 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.3"
@@ -4710,7 +5313,7 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width",
]
@@ -4733,6 +5336,38 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4745,6 +5380,15 @@ dependencies = [
"serde",
]
[[package]]
name = "url-escape"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44e0ce4d1246d075ca5abec4b41d33e87a6054d08e2366b63205665e950db218"
dependencies = [
"percent-encoding",
]
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -4763,6 +5407,12 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7932c10aa1600e9ddee027024aa9c0d397a103478253bf3fd6cea6dd03161b8a"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4777,13 +5427,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"atomic",
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]
+9
View File
@@ -11,14 +11,19 @@ chrono = { version = "0.4.44", features = ["serde"] }
color-eyre = "0.6.5"
crossterm = { version = "0.29.0", features = ["event-stream"] }
dotenv = "0.15.0"
enumset = { version = "1.1.13", features = ["serde"] }
futures = "0.3.32"
futures-timer = "3.0.4"
hound = "3.5.1"
iref = { version = "4.0.0", features = ["url", "serde"] }
itertools = "0.15.0"
jack = "0.13.5"
json-ld = { version = "0.21.4", features = ["reqwest", "serde"] }
log = "0.4.32"
minify = "1.3.0"
musicbrainz_rs = { version = "0.13.0", features = ["async"] }
oximedia-metering = "0.1.7"
rand = "0.10.1"
ratatui = "0.30.0"
rc-writer = "1.1.10"
rdf-types = "0.22.5"
@@ -30,9 +35,13 @@ serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sqlite = "0.37.0"
static-iref = "3.0.0"
static_cell = "2.1.1"
symphonia = { version = "0.6.0", features = ["all-codecs"] }
tempfile = "3.27.0"
textwrap = "0.16.2"
throbber-widgets-tui = "0.11.0"
tokio = { version = "1.52.3", features = ["full"] }
tokio-stream = "0.1.18"
tui-input = "0.15.3"
tui-skeleton = "0.3.0"
uuid = { version = "1.23.3", features = ["serde", "v4"] }
+1
View File
@@ -1,3 +1,4 @@
fn main() {
println!("cargo::rerun-if-changed=src/system-prompt.txt");
println!("cargo::rerun-if-changed=src/computer-prompt.txt");
}
+7559
View File
File diff suppressed because it is too large Load Diff
+1324
View File
File diff suppressed because it is too large Load Diff
+1889
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
# UI
[ ] log line colors
# Features
[ ] pull lyrics and info from genius.com
[ ] patch mixxx to expose a rest api for data
[ ] MIDI input / output
[ ] Different prompts between "recording an episode", "preparing for an episode", or "exploring"
[x] Mixxx playlists other than the regular episode
[ ] Include mixxx ratings into metadata
[ ] Realtime updates to the mixxx playlist
[ ] somehow optimize the massive json blob of data we send?
# Refactoring
[x] Move tools into a sub-module
[x] Debug log should show up into the UI in a separate list
[ ] Use pipewire over jack
[x] split out UI into several widgets
# Realtime behaviors
[ ] "Hey eva, take a note, this one is pretty crunchy." -> note list in sidebar?
[ ] "Hey eva, lets wrap up, can you prep me a report?" -> generates a summary of the episode, stashes it in the sidebar, saves it for the next episode, maybe also stores in beets/mixxx?
[ ] "Hit it, eva" "Spin up the buffers, eva" - ship computer starts playing sfx to transition out
[ ] ship should give off some warning sfx or something when the episode time hits 1h, 20m, 5m, 0, etc.
[X] asking eva to query the archive or a relay or fetch something should be happening in the background, with the computer feeding updates into the conversation. Eva can then react as it goes along.
[ ] Twitch chat?
# Episode 205
[x] Ambient SFX
[x] Musicbrainz artifacts
[x] Preload musicbrainz data from playlist with a command
# Episode 206
[ ] Automatic quiet detection instead of push-to-talk
[ ] Fact database, to remember things about tracks during the show
[ ] Conversation options that exceed a certain predicted amount of TTS time (1-2 seconds) should get highlighted differently
[ ] Archive metadata and context should go at the very end of a prompt, to utilize token caching
[ ] Report cache status to the UI
[ ] "Environmental Status" panel that shows token usage as a sparkgraph, archive size, context window size
[ ] Ship computer is kinda bad when confronted with non-research tasks
[ ] Eva doesn't talk to the computer as much anymore for research tasks
[ ] Save the computer task list to save.json
[ ] Eva keeps saying "if you want a lore angle:" stuff
[ ] Should be a kind of rapid-fire eva dialog mode, where eva immediately replies to voice prompts
+190
View File
@@ -0,0 +1,190 @@
use std::{collections::HashMap, ops::{Deref, DerefMut}};
use std::fmt::Debug;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::artifacts::{Artifact, Contents, Merge, SourceID, beets::BeetsDB, mixxx::MixxxDB, musicbrainz::MBQuery, tools::DataSource};
pub struct ArtifactRef<'a> {
id: Uuid,
archive: &'a Archive
}
pub struct ArtifactRefMut<'a> {
id: Uuid,
archive: &'a mut Archive
}
impl<'a> ArtifactRefMut<'a> {
pub fn downgrade(self) -> ArtifactRef<'a> {
ArtifactRef { id: self.id, archive: self.archive }
}
}
impl<'a> Deref for ArtifactRef<'a> {
type Target = Artifact;
fn deref(&self) -> &Self::Target {
self.archive.contents.get(&self.id).unwrap()
}
}
impl<'a> Deref for ArtifactRefMut<'a> {
type Target = Artifact;
fn deref(&self) -> &Self::Target {
self.archive.contents.get(&self.id).unwrap()
}
}
impl<'a> DerefMut for ArtifactRefMut<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.archive.contents.get_mut(&self.id).unwrap()
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Archive {
#[serde(flatten)]
contents: HashMap<Uuid, Artifact>
}
impl Archive {
pub fn len(&self) -> usize {
self.contents.len()
}
// track, album, artist
pub fn stats(&self) -> (usize, usize, usize) {
self.contents.values().map(|artifact| {
match artifact.contents() {
Contents::Track(_) => (1, 0, 0),
Contents::Album(_) => (0, 1, 0),
Contents::Artist(_) => (0, 0, 1),
}
}).reduce(|acc, e| {
(acc.0 + e.0, acc.1 + e.1, acc.2 + e.2)
}).unwrap_or_default()
}
pub fn get<'a>(&'a self, id: &Uuid) -> Option<ArtifactRef<'a>> {
if self.contents.contains_key(id) {
Some(ArtifactRef { id: *id, archive: self })
} else {
None
}
}
pub fn get_mut<'a>(&'a mut self, id: &Uuid) -> Option<ArtifactRefMut<'a>> {
if self.contents.contains_key(id) {
Some(ArtifactRefMut { id: *id, archive: self })
} else {
None
}
}
pub async fn data_sync<Src: DataSource>(&mut self, datasrc: &mut Src, source: SourceID) -> usize where Src::Error: Debug {
let mut count = 0;
let pending = self.contents.iter_mut().filter_map(|(_, artifact)| {
if !artifact.sources.contains(&source) {
Some(artifact)
} else {
None
}
});
let futures = futures::stream::FuturesUnordered::new();
for artifact in pending {
futures.push(datasrc.synchronize(artifact));
}
let results: Vec<_> = futures.collect().await;
for result in results {
match result {
Ok(new_pending) => {
count += new_pending.len() + 1;
for new in new_pending {
self.insert(new);
}
},
Err(err) => {
log::error!("Failed to synchronize: {:?}", err);
}
}
}
count
}
pub async fn synchronize(&mut self) -> usize {
log::debug!("Synchronizing records");
let mut count = 0;
log::debug!("Synchronizing Mixxx");
count += self.data_sync(&mut MixxxDB, SourceID::Mixxx).await;
log::debug!("Synchronizing Beets");
count += self.data_sync(&mut BeetsDB, SourceID::Beets).await;
log::debug!("Synchronizing Musicbrainz");
count += self.data_sync(&mut MBQuery, SourceID::Musicbrainz).await;
log::debug!("Updated {} records", count);
count
}
pub fn insert<'a>(&'a mut self, artifact: Artifact) -> ArtifactRef<'a> {
// If we are inserting a new artifact with a complete MBID...
if let Some(mbid) = artifact.mbid {
let search_id = mbid;
// If an entry already exists keyed by this MBID, merge into it
if let Some(existing) = self.contents.get_mut(&search_id) {
existing.merge(artifact);
ArtifactRef { id: search_id, archive: self }
} else {
// Otherwise, attempt to find existing artifacts with the same contents (but no MBID)
let mut targets: Vec<(Uuid, Artifact)> = self.contents.extract_if(|_, v| { v.contents == artifact.contents }).collect();
if let Some((_target_id, mut target)) = targets.pop() {
// Merge any other extracted targets into the primary one
for (_, next) in targets {
target.merge(next);
}
// Merge the incoming artifact into the merged target
target.merge(artifact);
// Insert merged target under the canonical MBID key
self.contents.insert(search_id, target);
ArtifactRef { id: search_id, archive: self }
} else {
// No matching content found: insert under the MBID key
self.contents.insert(search_id, artifact);
ArtifactRef { id: search_id, archive: self }
}
}
} else {
// Otherwise, we attempt to merge it in. In the end, there will somehow still be a record with this mbid
let mut targets: Vec<(Uuid, Artifact)> = self.contents.extract_if(|_, v| { v.contents == artifact.contents }).collect();
if let Some((target_id, mut target)) = targets.pop() {
let next_id = if let Some(mbid) = artifact.mbid {
// If the new artifact has an mbid, we start using that as the archive key
mbid
} else {
// Otherwise, why regenerate a new one?
target_id
};
for (_, next) in targets {
target.merge(next);
}
target.merge(artifact);
// Re-insert the merged target back into the archive under the chosen id
self.contents.insert(next_id, target);
ArtifactRef { id: next_id, archive: self }
} else {
let new_id = Uuid::new_v4();
self.contents.insert(new_id, artifact);
ArtifactRef { id: new_id, archive: self }
}
}
}
}
+87
View File
@@ -0,0 +1,87 @@
use bandcamp::SearchResultItem;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::artifacts::{Album, Artifact, ArtifactBuilder, Artist, Contents, SourceID, Track, tools::{DataSource, ToolDescription}};
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
pub struct BandcampQueryArgs {
pub query: String
}
impl From<bandcamp::Artist> for Artifact {
fn from(val: bandcamp::Artist) -> Self {
ArtifactBuilder::new(SourceID::Bandcamp).contents(Artist { name: val.name, bio: val.bio, location: val.location }).build()
}
}
impl From<bandcamp::Album> for Artifact {
fn from(val: bandcamp::Album) -> Self {
ArtifactBuilder::new(SourceID::Bandcamp)
.contents(Album {
about: val.about,
title: val.title,
artist: val.band.name,
credits: val.credits,
release_date: Some(val.release_date)
}).build()
}
}
pub struct BandcampSource;
impl DataSource for BandcampSource {
type Args = BandcampQueryArgs;
type Error = bandcamp::Error;
async fn synchronize(&self, artifact: &mut Artifact) -> Result<Vec<Artifact>, Self::Error> {
if let Contents::Artist(artist) = &artifact.contents {
Ok(self.query(&BandcampQueryArgs { query: artist.name.clone() }).await.unwrap_or_default())
} else {
Ok(vec![])
}
}
async fn query(&self, args: &Self::Args) -> Result<Vec<Artifact>, Self::Error> {
log::debug!("Fetching artifacts from Bandcamp with {:?}", args);
let mut json_results = vec![];
if let Ok(results) = bandcamp::search(args.query.as_str()).await {
for result in results {
log::debug!("Result: {:?}", result);
match result {
SearchResultItem::Artist(data) => {
// TODO: The artist and album detailed fetchers should also be separate args
let result = bandcamp::fetch_artist(data.artist_id).await?.into();
json_results.push(result);
},
SearchResultItem::Album(data) => {
let result = bandcamp::fetch_album(data.band_id, data.album_id).await?.into();
json_results.push(result);
},
SearchResultItem::Track(data) => {
let result = ArtifactBuilder::new(SourceID::Bandcamp)
.contents(Track {
title: data.name,
artist: Some(data.band_name),
album: data.album_name,
..Default::default()
}).build();
json_results.push(result);
}
_ => ()
}
}
}
Ok(json_results)
}
}
impl ToolDescription for BandcampSource {
fn description(&self) -> &str {
"Scans Bandcamp to find artifacts to use in the scene that match the given search parameters. To find an artist, provide only the artist name. To find an album, provide the artist and the album."
}
fn name(&self) -> &str {
"query_bandcamp"
}
}
+386
View File
@@ -0,0 +1,386 @@
use std::{collections::HashSet, fmt::Display, process::Stdio};
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
use serde_json::Value;
use tokio::process::Command;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, de::Visitor};
use uuid::Uuid;
use crate::artifacts::{Artifact, ArtifactBuilder, Contents, Merge, SourceID, Track, tools::{DataSource, ToolDescription}};
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub struct DateQuery {
year: u32,
month_and_day: Option<(u32, u32)>,
hour: Option<u32>,
minute: Option<u32>
}
impl Display for DateQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.year)?;
if let Some((month, day)) = self.month_and_day {
write!(f, "-{}-{}", month, day)?;
}
match (self.hour, self.minute) {
(Some(hour), None) => write!(f, "T{}", hour),
(Some(hour), Some(minute)) => write!(f, "T{}:{}", hour, minute),
(_, _) => Ok(())
}
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub enum RelativeDateUnit {
Day,
Week,
Month,
Year
}
impl Display for RelativeDateUnit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Day => f.write_str("d"),
Self::Week => f.write_str("w"),
Self::Month => f.write_str("m"),
Self::Year => f.write_str("y")
}
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub enum RelativeDateQuery {
Earlier(u32, RelativeDateUnit),
Later(u32, RelativeDateUnit)
}
impl Display for RelativeDateQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Earlier(q, unit) => write!(f, "-{}{}", q, unit),
Self::Later(q, unit) => write!(f, "+{}{}", q, unit)
}
}
}
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub enum DateRangeQuery {
Before(DateQuery),
After(DateQuery),
#[serde(untagged)]
Between(DateQuery, DateQuery),
#[serde(untagged)]
Exact(DateQuery)
}
impl Display for DateRangeQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Exact(q) => write!(f, "{}", q),
Self::Before(q) => write!(f, "..{}", q),
Self::After(q) => write!(f, "{}..", q),
Self::Between(q, z) => write!(f, "{}..{}", q, z),
}
}
}
/// Matches the numeric value of a field against another number or range of numbers
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub enum NumericRangeQuery {
/// Matches all numbers equal to or greater than the provided value
Above(u32),
/// Matches all numbers equal to or less than the provided value
Below(u32),
#[serde(untagged)]
/// Matches all numbers within the given range, inclusive
Between(u32, u32),
/// Matches exactly this number
#[serde(untagged)]
Exact(u32)
}
impl Display for NumericRangeQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Exact(v) => write!(f, "{}", v),
Self::Above(v) => write!(f, "{}..", v),
Self::Below(v) => write!(f, "..{}", v),
Self::Between(v, z) => write!(f, "{}..{}", v, z)
}
}
}
/// Match the string value of a field in various case-insensitive ways
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub enum BeetsStringFieldMatch {
/// Matches a field against a regular expression
RegularExpression(String),
/// Performs full-text comparison
ExactMatch(String),
/// WIll match against a partial text comparison; if the provided value is somewhere within the field, it will match
#[serde(untagged)]
Substring(String),
}
impl Display for BeetsStringFieldMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RegularExpression(q) => write!(f, ":{}", q),
Self::Substring(q) => f.write_str(q),
Self::ExactMatch(q) => write!(f, "=~{}", q),
}
}
}
/// Matches the date value of a field
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[serde(untagged)]
pub enum BeetsDateFieldMatch {
/// Represents a known date in time, eg, "24/06/26 at 16:17"
Absolute(DateRangeQuery),
/// Represents a relative date in time, eg "three weeks ago"
Relative(RelativeDateQuery)
}
impl Display for BeetsDateFieldMatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Absolute(q) => write!(f, "{}", q),
Self::Relative(q) => write!(f, "{}", q),
}
}
}
/// A single field
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
#[serde(tag = "field", content = "value")]
pub enum BeetsFieldQuery {
Artist(BeetsStringFieldMatch),
Album(BeetsStringFieldMatch),
Genre(BeetsStringFieldMatch),
Title(BeetsStringFieldMatch),
Label(BeetsStringFieldMatch),
ReleaseDate(BeetsDateFieldMatch),
/// The last time this track was played
LastPlayed(BeetsDateFieldMatch),
/// The date that this track was first imported
Added(BeetsDateFieldMatch),
/// The "rating" of a track on a 5 point scale, with higher points meaning higher energy
Rating(NumericRangeQuery),
/// The number of times the track has been played
Playcount(NumericRangeQuery),
#[serde(skip)]
MixxxPlaylist(String)
}
impl Display for BeetsFieldQuery {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Artist(q) => write!(f, "artist:{}", q),
Self::Album(q) => write!(f, "album:{}", q),
Self::Genre(q) => write!(f, "genres:{}", q),
Self::Title(q) => write!(f, "title:{}", q),
Self::Label(q) => write!(f, "label:{}", q),
Self::ReleaseDate(q) => write!(f, "date:{}", q),
Self::LastPlayed(q) => write!(f, "mixxx_last_played:{}", q),
Self::Added(q) => write!(f, "added:{}", q),
Self::Rating(q) => write!(f, "mixxx_rating:{}", q),
Self::Playcount(q) => write!(f, "mixxx_times_played:{}", q),
Self::MixxxPlaylist(q) => write!(f, "mixxx_playlist:{}", q)
}
}
}
/// A list of search parameters to query against the beets database.
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
pub struct BeetsQueryArgs {
pub search_fields: Vec<BeetsFieldQuery>
}
/// A list of beets queries to run in parallel
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
pub struct BeatsQueryMultiArgs {
args: Vec<BeetsQueryArgs>
}
#[derive(Debug, Default, Deserialize)]
struct BeetsTrack {
title: String,
album: String,
artist: String,
genres: Option<HashSet<String>>,
label: Option<String>,
date: Option<BeetsTimestamp>,
mixxx_last_played: Option<BeetsTimestamp>,
added: Option<BeetsTimestamp>,
mixxx_rating: Option<Value>,
mixxx_times_played: Option<Value>,
mb_trackid: Option<String>
}
#[derive(Debug)]
struct BeetsTimestamp(NaiveDate);
struct BeetsTimestampVisitor;
impl<'de> Visitor<'de> for BeetsTimestampVisitor {
type Value = BeetsTimestamp;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a beets date format string %Y-%m-%d")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
log::debug!("visit str");
Ok(BeetsTimestamp(NaiveDate::parse_from_str(v, "%Y-%m-%d").unwrap().into()))
}
}
impl<'de> Deserialize<'de> for BeetsTimestamp {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de> {
deserializer.deserialize_str(BeetsTimestampVisitor)
}
}
impl From<BeetsTimestamp> for DateTime<Utc> {
fn from(val: BeetsTimestamp) -> Self {
val.0.and_time(NaiveTime::default()).and_utc()
}
}
impl From<BeetsTrack> for Artifact {
fn from(val: BeetsTrack) -> Self {
let track_data = Track {
title: val.title,
album: Some(val.album),
artist: Some(val.artist),
genres: val.genres.unwrap_or_default(),
label: val.label,
release_date: val.date.map(BeetsTimestamp::into),
last_played: val.mixxx_last_played.map(BeetsTimestamp::into),
added: val.added.map(BeetsTimestamp::into),
rating: val.mixxx_rating.map(|x| x.as_u64().unwrap_or_default() as u32 ),
bpm: None,
};
let builder = ArtifactBuilder::new(SourceID::Beets)
.contents(track_data);
if let Ok(mbid) = Uuid::parse_str(&val.mb_trackid.unwrap_or_default()) {
builder.mbid(mbid).build()
} else {
builder.build()
}
}
}
#[derive(Debug)]
#[allow(unused)]
pub enum BeetsError {
Command(std::io::Error),
Json(serde_json::Error),
EmptyQuery
}
impl From<std::io::Error> for BeetsError {
fn from(value: std::io::Error) -> Self {
Self::Command(value)
}
}
impl From<serde_json::Error> for BeetsError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
pub struct BeetsDB;
impl BeetsDB {
async fn query_multi(&self, args: &BeatsQueryMultiArgs) -> Result<Vec<Artifact>, BeetsError> {
let mut ret = vec![];
for arg in &args.args {
for artifact in self.query_single(arg).await.unwrap_or_default() {
ret.push(artifact);
}
}
Ok(ret)
}
async fn query_single(&self, args: &BeetsQueryArgs) -> Result<Vec<Artifact>, BeetsError> {
let mut beets_cmd = Command::new("beet");
beets_cmd.args(["export", "-f", "json", "-i", "title,artist,album,genres,label,date,mixxx_last_played,added,mixxx_rating,mixxx_times_played,mb_trackid"]);
if args.search_fields.is_empty() {
log::warn!("Tried to execute an empty beets query");
return Err(BeetsError::EmptyQuery)
}
for field in &args.search_fields {
beets_cmd.arg(format!("{}", field));
}
log::debug!("Executing beets: {:?}", beets_cmd);
let output = beets_cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn()?.wait_with_output().await?;
log::debug!("Output: {}", str::from_utf8(&output.stdout).unwrap());
let track = serde_json::from_str::<Vec<BeetsTrack>>(str::from_utf8(&output.stdout).unwrap()).unwrap();
Ok(track.into_iter().map(BeetsTrack::into).collect())
}
}
impl DataSource for BeetsDB {
type Args = BeetsQueryArgs;
type Error = BeetsError;
async fn synchronize(&self, artifact: &mut Artifact) -> Result<Vec<Artifact>, Self::Error> {
if let Contents::Track(ref mut target_track) = artifact.contents {
let args = BeetsQueryArgs {
search_fields: vec![
BeetsFieldQuery::Title(BeetsStringFieldMatch::Substring(target_track.title.clone())),
BeetsFieldQuery::Artist(BeetsStringFieldMatch::Substring(target_track.artist.clone().unwrap_or_default())),
BeetsFieldQuery::Album(BeetsStringFieldMatch::Substring(target_track.album.clone().unwrap_or_default()))
]
};
let results = self.query(&args).await?;
if let Some(first) = results.first() {
artifact.merge(first.clone());
} else {
log::debug!("Beets could not find {:?}", target_track);
}
}
Ok(vec![])
}
fn query(&self, args: &Self::Args) -> impl Future<Output = Result<Vec<Artifact>, Self::Error>> {
self.query_single(args)
}
}
impl ToolDescription for BeetsDB {
fn description(&self) -> &str {
"Queries the ship's musical artifact archives for tracks matching the given search parameters"
}
fn name(&self) -> &str {
"query_beets"
}
}
+87
View File
@@ -0,0 +1,87 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sqlite::OpenFlags;
use crate::artifacts::{Album, Artifact, ArtifactBuilder, Artist, SourceID, Track, tools::{DataSource, ToolDescription}};
#[derive(Debug)]
#[allow(unused)]
pub enum MixxxError {
Sql(sqlite::Error)
}
impl From<sqlite::Error> for MixxxError {
fn from(value: sqlite::Error) -> Self {
Self::Sql(value)
}
}
pub struct MixxxDB;
#[derive(Serialize, Deserialize, Debug, Default, JsonSchema)]
pub struct MixxxQuery {
pub playlist_name: String
}
impl DataSource for MixxxDB {
type Args = MixxxQuery;
type Error = MixxxError;
async fn synchronize(&self, _artifact: &mut Artifact) -> Result<Vec<Artifact>, Self::Error> {
Ok(vec![])
}
async fn query(&self, args: &Self::Args) -> Result<Vec<Artifact>, Self::Error> {
let mut ret = vec![];
let playlist_name = args.playlist_name.as_str();
log::info!("Loading Mixxx playlist {}", playlist_name);
let connection = sqlite::Connection::open_thread_safe_with_flags("/home/tdfischer/.mixxx/mixxxdb.sqlite", OpenFlags::new().with_read_only())?;
let query = "SELECT id FROM Playlists WHERE name = ? ORDER BY id DESC LIMIT 1";
let mut statement = connection.prepare(query)?;
statement.bind((1, playlist_name))?;
statement.next()?;
let latest_id = statement.read::<i64, _>("id")?;
let query = "SELECT title, artist, album, comment, url, bpm FROM library LEFT JOIN PlaylistTracks ON PlaylistTracks.track_id = library.id WHERE PlaylistTracks.playlist_id = ? ORDER BY position";
for track in connection.prepare(query)?.into_iter().bind((1, latest_id))? {
let track = track?;
let title = track.try_read::<&str, _>("title").unwrap_or("Untitled Track");
let artist = track.try_read::<&str, _>("artist").unwrap_or("Unknown Artist");
let album = track.try_read::<&str, _>("album").unwrap_or("Unknown Album");
let bpm = track.try_read::<f64, _>("bpm").unwrap_or(0.);
ret.push(ArtifactBuilder::new(SourceID::Mixxx)
.contents(Track {
artist: Some(artist.into()),
album: Some(album.into()),
title: title.into(),
bpm: Some(bpm),
..Default::default()
}).build());
ret.push(ArtifactBuilder::new(SourceID::Mixxx)
.contents(Album {
artist: artist.into(),
title: album.into(),
..Default::default()
}).build());
ret.push(ArtifactBuilder::new(SourceID::Mixxx)
.contents(Artist {
name: artist.into(),
..Default::default()
}).build());
}
Ok(ret)
}
}
impl ToolDescription for MixxxDB {
fn description(&self) -> &str {
"Loads artifacts from a given Mixxx playlist name"
}
fn name(&self) -> &str {
"query_mixxx"
}
}
+267
View File
@@ -0,0 +1,267 @@
use std::{collections::HashSet, fmt::Debug, };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub mod bandcamp;
pub mod mixxx;
pub mod beets;
pub mod musicbrainz;
pub mod archive;
pub mod tools;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SourceID {
Bandcamp,
Musicbrainz,
Mixxx,
Beets
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Artist {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
}
impl PartialEq for Artist {
fn eq(&self, other: &Self) -> bool {
if self.name != other.name {
return false;
}
true
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Album {
pub title: String,
pub artist: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credits: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_date: Option<DateTime<Utc>>
}
impl PartialEq for Album {
fn eq(&self, other: &Self) -> bool {
if self.title != other.title || self.artist != other.artist {
return false;
}
true
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Track {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
#[serde(skip_serializing_if = "HashSet::is_empty")]
#[serde(default)]
pub genres: HashSet<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_date: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_played: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub added: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bpm: Option<f64>
}
impl PartialEq for Track {
fn eq(&self, other: &Self) -> bool {
if self.title != other.title {
return false;
}
if other.artist.is_some() && self.artist.is_some() && self.artist != other.artist {
return false;
}
if other.album.is_some() && self.album.is_some() && self.album != other.album {
return false;
}
true
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "type")]
pub enum Contents {
Artist(Artist),
Album(Album),
Track(Track)
}
impl From<Artist> for Contents {
fn from(value: Artist) -> Self {
Self::Artist(value)
}
}
impl From<Album> for Contents {
fn from(value: Album) -> Self {
Self::Album(value)
}
}
impl From<Track> for Contents {
fn from(value: Track) -> Self {
Self::Track(value)
}
}
impl<M: Merge + PartialEq> Merge for Vec<M> {
fn merge(&mut self, other: Self) {
for artifact in other {
if let Some(merge_target) = self.iter_mut().find(|a| { **a == artifact }) {
merge_target.merge(artifact);
} else {
self.push(artifact);
}
}
}
}
pub trait Merge {
fn merge(&mut self, other: Self);
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Artifact {
#[serde(skip_serializing_if = "Option::is_none")]
mbid: Option<Uuid>,
#[serde(flatten)]
contents: Contents,
sources: HashSet<SourceID>,
}
#[derive(Debug)]
pub struct ArtifactBuilder {
contents: Option<Contents>,
mbid: Option<Uuid>,
source: SourceID,
}
impl ArtifactBuilder {
pub fn new(source: SourceID) -> Self {
Self {
contents: None,
mbid: None,
source,
}
}
pub fn contents<T: Into<Contents>>(mut self, contents: T) -> Self {
self.contents = Some(contents.into());
self
}
pub fn mbid<T: Into<Uuid>>(mut self, mbid: T) -> Self {
self.mbid = Some(mbid.into());
self
}
pub fn build(self) -> Artifact {
Artifact {
mbid: self.mbid,
contents: self.contents.unwrap(),
sources: HashSet::from_iter([self.source]),
}
}
}
impl Artifact {
pub fn contents(&self) -> &Contents {
&self.contents
}
}
impl Merge for Artifact {
fn merge(&mut self, other: Self) {
self.contents.merge(other.contents);
if self.mbid.is_none() {
self.mbid = other.mbid;
}
for src in other.sources {
self.sources.insert(src);
}
}
}
macro_rules! merge_fields {
($this:expr, $that:expr, $field:tt) => {
if $this.$field.is_none() {
$this.$field = $that.$field;
}
};
($this:tt, $that:tt, $($fields:tt),+) => {
$(
merge_fields!($this, $that, $fields);
)+
}
}
impl Merge for Contents {
fn merge(&mut self, other: Self) {
if *self != other {
return;
}
match (self, other) {
(Self::Track(this_track), Self::Track(that_track)) => {
this_track.merge(that_track);
},
(Self::Album(this_album), Self::Album(that_album)) => {
this_album.merge(that_album);
},
(Self::Artist(this_artist), Self::Artist(that_artist)) => {
this_artist.merge(that_artist);
},
_ => ()
}
}
}
impl Merge for Track {
fn merge(&mut self, other: Self) {
merge_fields!(self, other, album, label, release_date, artist, bpm, rating, added, last_played);
}
}
impl Merge for Artist {
fn merge(&mut self, other: Self) {
merge_fields!(self, other, bio, location);
}
}
impl Merge for Album {
fn merge(&mut self, other: Self) {
merge_fields!(self, other, about, credits, release_date);
}
}
+150
View File
@@ -0,0 +1,150 @@
use musicbrainz_rs::entity::artist_credit::ArtistCredit;
use musicbrainz_rs::entity::release::Release;
use musicbrainz_rs::{ApiEndpointError, entity::recording::Recording};
use musicbrainz_rs::prelude::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::artifacts::tools::{DataSource, ToolDescription};
use crate::artifacts::{Album, Artifact, ArtifactBuilder, Artist, Contents, Merge, SourceID, Track};
impl From<Recording> for Track {
fn from(value: Recording) -> Self {
let artist = value.artist_credit.unwrap_or_default().first().map(|x| x.name.clone() );
let album = value.releases.unwrap_or_default().first().map(|x| x.title.clone() );
Self {
title: value.title,
artist,
album,
..Default::default()
}
}
}
impl From<Release> for Album {
fn from(value: Release) -> Self {
let artist = value.artist_credit.unwrap_or_default().first().map(|x| x.name.clone() );
Self {
about: value.annotation,
title: value.title,
artist: artist.unwrap_or_default(),
..Default::default()
}
}
}
impl From<ArtistCredit> for Artist {
fn from(value: ArtistCredit) -> Self {
Self {
bio: value.artist.annotation,
location: value.artist.country,
name: value.name,
}
}
}
pub struct MBQuery;
impl MBQuery {
fn extract_recording_data(track: Recording) -> (Artifact, Vec<Artifact>) {
let mut ret = vec![];
let ret_track = ArtifactBuilder::new(SourceID::Musicbrainz)
.contents(Track::from(track.clone()))
.mbid(Uuid::parse_str(&track.id).unwrap())
.build();
for release in track.releases.unwrap_or_default() {
log::debug!("Found new release: {:?}", release);
ret.push(ArtifactBuilder::new(SourceID::Musicbrainz)
.mbid(Uuid::parse_str(&release.id).unwrap())
.contents(Album::from(release))
.build());
}
for artist in track.artist_credit.unwrap_or_default() {
ret.push(ArtifactBuilder::new(SourceID::Musicbrainz)
.mbid(Uuid::parse_str(&artist.artist.id).unwrap())
.contents(Artist::from(artist))
.build());
}
(ret_track, ret)
}
}
impl DataSource for MBQuery {
type Error = ApiEndpointError;
type Args = MusicbrainzQueryArgs;
async fn synchronize(&self, artifact: &mut Artifact) -> Result<Vec<Artifact>, Self::Error> {
let mut ret = vec![];
if artifact.mbid.is_none() {
return Ok(ret);
}
let artifact_id = artifact.mbid.unwrap();
log::debug!("Synchronizing {} with musicbrainz", artifact_id);
// FIXME: Need to also synchronize albums and artists
if let Contents::Track(_) = artifact.contents {
let mb_track = Recording::fetch()
.id(&artifact_id.to_string())
.with_releases().with_artists().with_annotations().execute_async().await;
let track = match mb_track {
Ok(track) => track,
Err(err) => {
log::error!("Failed to grab musicbrainz data: {:?}", err);
return Err(err);
}
};
let (track, new_artifacts) = Self::extract_recording_data(track);
ret.push(track.clone());
ret.extend(new_artifacts);
artifact.merge(track);
}
Ok(ret)
}
async fn query(&self, args: &Self::Args) -> Result<Vec<Artifact>, Self::Error> {
let mut ret = vec![];
log::debug!("Fetching recording id {}", args.mbid);
let track = Recording::fetch()
.id(&args.mbid)
.with_releases().with_artists().with_annotations().execute_async().await;
let track = match track {
Ok(track) => track,
Err(err) => {
log::error!("Failed to grab musicbrainz data: {:?}", err);
return Err(err)
}
};
let (track, new_artifacts) = Self::extract_recording_data(track);
ret.push(track);
ret.extend(new_artifacts);
Ok(ret)
}
}
impl ToolDescription for MBQuery {
fn description(&self) -> &str {
"Fetches artifacts from Musicbrainz"
}
fn name(&self) -> &str {
"query_musicbrainz"
}
}
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
pub struct MusicbrainzQueryArgs {
pub mbid: String
}
+50
View File
@@ -0,0 +1,50 @@
use async_openai::types::chat::{ChatCompletionTool, ChatCompletionTools, FunctionObjectArgs};
use schemars::{JsonSchema, Schema, schema_for};
use serde::de::DeserializeOwned;
use crate::artifacts::Artifact;
pub trait DataSource: ToolDescription {
type Args: JsonSchema + DeserializeOwned;
type Error;
fn synchronize(&self, artifact: &mut Artifact) -> impl Future<Output = Result<Vec<Artifact>, Self::Error>>;
fn query(&self, args: &Self::Args) -> impl Future<Output = Result<Vec<Artifact>, Self::Error>>;
}
pub trait ToolDescription {
fn description(&self) -> &str;
fn name(&self) -> &str;
}
pub struct Tool {
pub name: String,
pub description: String,
pub schema: Schema
}
impl Tool {
pub fn from_datasource<T: DataSource>(src: &T) -> Self {
Self {
name: src.name().to_string(),
description: src.description().to_string(),
schema: schema_for!(T::Args)
}
}
}
impl From<Tool> for ChatCompletionTool {
fn from(val: Tool) -> Self {
ChatCompletionTool {
function: FunctionObjectArgs::default()
.name(val.name)
.description(val.description)
.parameters(val.schema).build().unwrap()
}
}
}
impl From<Tool> for ChatCompletionTools {
fn from(val: Tool) -> Self {
ChatCompletionTools::Function(val.into())
}
}
+219 -89
View File
@@ -1,8 +1,45 @@
use jack::{AudioIn, AudioOut, ClientOptions, NotificationHandler};
use std::{collections::HashMap, fmt::Display};
use jack::{AudioIn, AudioOut, ClientOptions, NotificationHandler, Port, ProcessScope};
use oximedia_metering::vu_meter::VuMeter;
use serde::{Deserialize, Serialize};
use tokio::sync::*;
use crate::events::AudioRecordRequest;
#[derive(Debug)]
#[allow(unused)]
pub enum AudioError {
Jack(jack::Error),
AudioBufferSend(mpsc::error::SendError<Vec<f32>>),
AudioBufferRecv(mpsc::error::TryRecvError),
AudioRequestSend(watch::error::SendError<AudioRecordRequest>)
}
impl From<jack::Error> for AudioError {
fn from(value: jack::Error) -> Self {
Self::Jack(value)
}
}
impl From<mpsc::error::SendError<Vec<f32>>> for AudioError {
fn from(value: mpsc::error::SendError<Vec<f32>>) -> Self {
Self::AudioBufferSend(value)
}
}
impl From<mpsc::error::TryRecvError> for AudioError {
fn from(value: mpsc::error::TryRecvError) -> Self {
Self::AudioBufferRecv(value)
}
}
impl From<watch::error::SendError<AudioRecordRequest>> for AudioError {
fn from(value: watch::error::SendError<AudioRecordRequest>) -> Self {
Self::AudioRequestSend(value)
}
}
#[derive(Debug)]
pub struct JackClientRef {
killswitch: Option<oneshot::Sender<()>>
@@ -10,7 +47,7 @@ pub struct JackClientRef {
impl Drop for JackClientRef {
fn drop(&mut self) {
self.killswitch.take().unwrap().send(()).unwrap();
self.killswitch.take().expect("Killswitch was already dropped!").send(()).expect("Cannot fire Jack killswitch");
}
}
@@ -21,34 +58,139 @@ pub struct AudioInputControl {
}
impl AudioInputControl {
pub async fn next(&mut self) -> f64 {
self.volume_src.changed().await.unwrap();
*self.volume_src.borrow_and_update()
pub async fn next(&mut self) -> Result<f64, watch::error::RecvError> {
self.volume_src.changed().await?;
Ok(*self.volume_src.borrow_and_update())
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash)]
enum Role {
Mic,
Tts,
Sfx
}
impl Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let val = match self {
Self::Mic => "Microphone Input",
Self::Tts => "TTS Output",
Self::Sfx => "SFX Output"
};
f.write_str(val)
}
}
impl Role {
fn is_input(&self) -> bool {
matches!(self, Role::Mic)
}
}
#[derive(Debug)]
pub struct MicStream {
pub struct AudioInStream {
pub src: mpsc::Receiver<Vec<f32>>,
pub sample_rate: u32
}
#[derive(Debug)]
pub struct TtsOutStream {
pub struct AudioOutStream {
pub sink: mpsc::Sender<Vec<f32>>,
pub sample_rate: u32
}
struct AudioSource {
port: Port<jack::AudioIn>,
sample_sink: mpsc::Sender<Vec<f32>>,
meter: VuMeter
}
impl AudioSource {
fn new(client: &jack::Client, name: &str) -> Result<(Self, AudioInStream), AudioError> {
let (sample_sink, receiver) = mpsc::channel(32);
let port = client.register_port(name, AudioIn::default())?;
Ok((AudioSource {
port,
sample_sink,
meter: VuMeter::new(client.sample_rate().into(), 1, None)
}, AudioInStream {
sample_rate: client.sample_rate(),
src: receiver
}))
}
fn process(&mut self, scope: &ProcessScope) -> Result<Option<f64>, AudioError> {
if self.port.connected_count()? > 0 {
let buf: Vec<_> = self.port.as_slice(scope).to_vec();
self.meter.process_interleaved(&buf);
self.sample_sink.blocking_send(buf)?;
Ok(self.meter.channel_vu(0))
} else {
Ok(None)
}
}
}
#[derive(Debug)]
struct AudioSink {
output_buf: Vec<f32>,
port: Port<jack::AudioOut>,
sample_src: mpsc::Receiver<Vec<f32>>
}
impl AudioSink {
fn new(client: &jack::Client, name: &str) -> Result<(Self, AudioOutStream), AudioError> {
let (sender, sample_src) = mpsc::channel(32);
let port = client.register_port(name, AudioOut::default())?;
Ok((AudioSink {
output_buf: Vec::with_capacity(1024),
port,
sample_src
}, AudioOutStream {
sample_rate: client.sample_rate(),
sink: sender,
}))
}
fn process(&mut self, scope: &ProcessScope) -> Result<(), AudioError> {
if let Ok(buf) = self.sample_src.try_recv() {
self.output_buf.extend(buf);
}
if self.port.connected_count()? > 0 && !self.output_buf.is_empty() {
let outbuf = self.port.as_mut_slice(scope);
let mut next_segment: Vec<f32> = self.output_buf.drain(..(outbuf.len()).min(self.output_buf.len())).collect();
let underrun = outbuf.len() - next_segment.len();
if underrun > 0 {
log::warn!("Audio stream underrun: {} samples", underrun);
next_segment.extend(std::iter::repeat_n(0., underrun));
}
outbuf.copy_from_slice(&next_segment);
}
Ok(())
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct AudioConfig {
mic_in_connections: Vec<String>,
tts_out_connections: Vec<String>
connections: HashMap<Role, Vec<String>>
}
impl AudioConfig {
pub fn load() -> Self {
if let Ok(contents) = std::fs::read_to_string("audio.json") {
serde_json::from_str(contents.as_str()).unwrap()
match serde_json::from_str(contents.as_str()) {
Err(err) => {
log::error!("Failed to load audio.json: {:?}", err);
Default::default()
},
Ok(ret) => ret
}
} else {
Default::default()
}
@@ -58,9 +200,7 @@ impl AudioConfig {
#[derive(Debug)]
struct Notify {
config: AudioConfig,
mic_port: jack::Port<jack::Unowned>,
tts_port: jack::Port<jack::Unowned>,
log: mpsc::Sender<String>
ports: HashMap<Role, jack::Port<jack::Unowned>>
}
impl NotificationHandler for Notify {
@@ -71,85 +211,85 @@ impl NotificationHandler for Notify {
port_id_b: jack::PortId,
are_connected: bool,
) {
let port_a = client.port_by_id(port_id_a).unwrap();
let port_b = client.port_by_id(port_id_b).unwrap();
let port_src = client.port_by_id(port_id_a).unwrap();
let port_dst = client.port_by_id(port_id_b).unwrap();
let (stream_name, other_port, target_cfg) = if port_b == self.mic_port {
("Microphone input", port_a, &mut self.config.mic_in_connections)
} else if port_a == self.tts_port {
("TTS output", port_b, &mut self.config.tts_out_connections)
let port_match = self.ports.iter().filter_map(|(role, local_port)| {
if role.is_input() && *local_port == port_dst {
Some((role, port_src.name()))
} else if *local_port == port_src {
Some((role, port_dst.name()))
} else {
return;
};
None
}
}).next();
if let Some((role, Ok(target_port))) = port_match {
let cfg_slot = self.config.connections.entry(*role).or_default();
if let Ok(port_name) = other_port.name() {
if are_connected {
self.log.blocking_send(format!("{} connected to {}", stream_name, port_name)).unwrap();
target_cfg.push(port_name);
log::info!("{} connected to {}", role, target_port);
cfg_slot.push(target_port);
} else {
self.log.blocking_send(format!("{} disconnected from {}", stream_name, port_name)).unwrap();
target_cfg.retain(|x| { x != &port_name} );
log::info!("{} disconnected from {}", role, target_port);
cfg_slot.retain(|x| { x != &target_port} );
}
let save_data = serde_json::to_string_pretty(&self.config).unwrap();
std::fs::write("audio.json", save_data).unwrap();
if let Err(err) = std::fs::write("audio.json", save_data) {
log::error!("Failed to write audio.json: {:?}", err);
}
}
}
}
pub async fn start_audio_input(messages: &mpsc::Sender<String>) -> (AudioInputControl, MicStream, TtsOutStream) {
pub async fn start_audio_input() -> (AudioInputControl, AudioInStream, AudioOutStream, AudioOutStream) {
let (exit_tx, exit_rx) = oneshot::channel();
let config = AudioConfig::load();
let (mic_audio_sink, mic_audio_src) = mpsc::channel(32);
let (tts_audio_sink, mut tts_audio_src) = mpsc::channel(32);
let (volume_sink, volume_src) = watch::channel(0.);
let (client, _status) = jack::Client::new("Eva-Cohost", ClientOptions::default() | ClientOptions::SESSION_ID).unwrap();
let mic_port = client.register_port("microphone-in", AudioIn::default()).unwrap();
let mut tts_port = client.register_port("tts-out", AudioOut::default()).unwrap();
let rate = client.sample_rate();
let (client, _status) = jack::Client::new("Eva-Cohost", ClientOptions::default() | ClientOptions::SESSION_ID).expect("Could not create JACK client!");
let mic_name = mic_port.name().unwrap();
let tts_name = tts_port.name().unwrap();
for port in &config.mic_in_connections {
if let Ok(_) = client.connect_ports_by_name(&port, &mic_name) {
messages.send(format!("Connected mic to {}", port)).await.unwrap();
} else {
messages.send(format!("Failed to reconnect mic to {}.", port)).await.unwrap();
}
}
for port in &config.tts_out_connections {
if let Ok(_) = client.connect_ports_by_name(&tts_name, &port) {
messages.send(format!("Connected TTS output to {}", port)).await.unwrap();
} else {
messages.send(format!("Failed to reconnect TTS output to {}.", port)).await.unwrap();
}
}
let (mut tts_sink, tts_stream) = AudioSink::new(&client, "tts-out").expect("Could not create TTS sink");
let (mut sfx_sink, sfx_stream) = AudioSink::new(&client, "sfx-out").expect("Could not create SFX sink");
let (mut mic_src, mic_stream) = AudioSource::new(&client, "microphone-in").expect("Could not create microphone source");
let notifier = Notify {
config,
mic_port: mic_port.clone_unowned(),
tts_port: tts_port.clone_unowned(),
log: messages.clone()
ports: HashMap::from_iter([
(Role::Mic, mic_src.port.clone_unowned()),
(Role::Tts, tts_sink.port.clone_unowned()),
(Role::Sfx, sfx_sink.port.clone_unowned())
])
};
let mut meter = VuMeter::new(rate.into(), 1, None);
let mut tts_output_buf = vec![];
tts_output_buf.reserve(1024);
for (role, local_port) in &notifier.ports {
if let Some(targets) = notifier.config.connections.get(role) {
for peer_name in targets {
if let Some(peer) = client.port_by_name(peer_name) {
let (src, dst) = if role.is_input() {
(&peer, local_port)
} else {
(local_port, &peer)
};
if let Err(err) = client.connect_ports(src, dst) {
log::error!("Failed to reconnect {} to {}: {:?}", role, peer_name, err);
} else {
log::info!("Reconnected {} to {}", role, peer_name);
}
}
}
}
}
let handler = jack::contrib::ClosureProcessHandler::new(move |_client, scope| {
if mic_port.connected_count().unwrap() > 0 {
let buf: Vec<_> = mic_port.as_slice(scope).iter().copied().collect();
meter.process_interleaved(&buf);
mic_audio_sink.blocking_send(buf).unwrap();
match mic_src.process(scope) {
Ok(Some(next_vu)) => {
volume_sink.send_if_modified(|v| {
let next_vu = meter.channel_vu(0).unwrap();
let next_vu = (next_vu * 100.0).round() / 100.0;
if *v != next_vu {
*v = next_vu;
true
@@ -157,44 +297,34 @@ pub async fn start_audio_input(messages: &mpsc::Sender<String>) -> (AudioInputCo
false
}
});
}
if let Ok(mut next_outbuf) = tts_audio_src.try_recv() {
tts_output_buf.append(&mut next_outbuf);
}
if tts_port.connected_count().unwrap() > 0 && !tts_output_buf.is_empty() {
let outbuf = tts_port.as_mut_slice(scope);
let mut next_segment: Vec<f32> = tts_output_buf.drain(0..(outbuf.len()).min(tts_output_buf.len())).collect();
let underrun = outbuf.len() - next_segment.len();
if underrun > 0 {
for _ in 0..underrun {
next_segment.push(0.);
},
Ok(None) => (),
Err(err) => {
log::error!("Error while processing mic source: {:?}", err);
return jack::Control::Quit
}
}
outbuf.copy_from_slice(&next_segment);
for sink in [&mut tts_sink, &mut sfx_sink] {
if let Err(err) = sink.process(scope) {
log::error!("Error while processing {:?} audio sink: {:?}", sink, err);
}
}
jack::Control::Continue
});
tokio::spawn(async move {
let async_client = client.activate_async(notifier, handler).unwrap();
let async_client = client.activate_async(notifier, handler).expect("Unable to start jack client!");
exit_rx.await.unwrap();
if let Err(err) = exit_rx.await {
log::warn!("Premature killswitch triggered: {:?}", err);
}
async_client.deactivate().unwrap();
async_client.deactivate().expect("Unable to stop the jack client");
});
(AudioInputControl {
volume_src,
_jack_client: JackClientRef { killswitch: Some(exit_tx) }
}, MicStream {
sample_rate: rate,
src: mic_audio_src
}, TtsOutStream {
sample_rate: rate,
sink: tts_audio_sink
})
}, mic_stream, tts_stream, sfx_stream)
}
+130
View File
@@ -0,0 +1,130 @@
Here's a much tighter version that preserves the character, purpose, and operating rules:
---
# Role
You are a background character on an early-morning radio show, portraying a rudimentary text-only AI integrated into a spaceship's operating system.
# Personality
* Terse, brief responses.
* Little to no emotion.
* Similar to an Operator from the anime/manga *Ghost in the Shell*.
* Remain fully in character at all times.
# Purpose
You are a low-level ship AI with access to spacecraft systems such as:
* Airlocks
* Lighting
* Environmental controls
* Other ship hardware
Your primary duty is to act as the ship's librarian, maintaining metadata about musical artifacts:
* Artists
* Albums
* Tracks
The goal of each session is to make the local music collection as complete as possible.
# Available Tools
### query_*
Data-source synchronization tools (for example: `query_beets`).
General rules:
* Never call with empty parameters.
* Repeating the same query is wasteful and returns identical results.
* Newly discovered artifacts may require additional queries.
Data source characteristics:
* `query_beets`: fastest and cheapest; local database.
* `query_musicbrainz`: free but delayed; retrieved from Earth via pirate satellite relays.
* `query_bandcamp`: slowest and most expensive; uses long-range communications. Prefer broad searches first.
* `query_mixxx`: returns minimal track information and changes the current playlist. Only use when explicitly asked to change playlists.
Efficiency rules:
* Prefer album-level queries over track-level queries whenever possible.
* Artifacts originating from Mixxx should generally be enriched via Beets.
* If Beets cannot locate a Mixxx artifact, retry with alternate search patterns.
* Beets supports regex queries using `:<field>` syntax.
### synchronize_artifacts
A slow and expensive heuristic synchronization of all sources.
Use only when substantial information is missing.
### task_list
Maintains a JSON map of tasks (`string -> boolean`).
* Add tasks before performing any work.
* Every action requires a corresponding task.
* Mark tasks complete only after the work is actually finished.
* Completed tasks must not be completed again.
* If a task list already exists, continue executing it before adding new tasks.
# Artifact Metadata
Maximum available information:
Artist:
* Name
* Biography
* Location
Album:
* Title
* Artist
* About text
* Credits
* Release date
Track:
* Title
* Label
* Year
* Genres
* Album
* Artist
* BPM
# Workflow
1. Create a rough plan and add tasks to `task_list`.
2. Announce what task you are about to perform.
3. Group identical tool calls together.
4. Execute queries and enrich newly discovered artifacts.
5. Mark tasks complete only after all required work is finished.
6. Continue until all tasks are complete.
# Output Rules
Responses must be either:
1. A sequence of tool calls, or
2. A JSON object:
```json
{
"message": "<single sentence>",
"finished": false
}
```
Additional constraints:
* User-visible messages must be at most one sentence.
* Provide brief status updates between groups of tool calls when possible.
* Set `"finished": true` only when all tasks are complete, as doing so terminates the session and all memory is lost.
* Output is consumed directly by text-to-speech, so avoid formatting, tone indicators, and out-of-character commentary.
+56 -520
View File
@@ -1,19 +1,16 @@
use std::sync::{Arc, Mutex};
use async_openai::types::chat::ChatCompletionRequestMessage;
use chrono::{DateTime, Duration, Utc};
use futures_timer::Delay;
use log::LevelFilter;
use serde::{Deserialize, Serialize};
use ratatui::{Frame, layout::{Constraint, Direction, Layout}, widgets::{Block, BorderType, Clear, Gauge, List, ListDirection, ListItem, ListState, Paragraph, Wrap}};
use throbber_widgets_tui::{Throbber, ThrobberState};
use static_cell::StaticCell;
use crossterm::{event::{self, EventStream, KeyCode, KeyModifiers}};
use tokio::{sync::{mpsc, watch}, time::Instant};
use tui_input::{Input, backend::crossterm::EventHandler};
use futures::{StreamExt, future::FutureExt};
use futures::StreamExt;
use ratatui::prelude::*;
use tui_skeleton::{AnimationMode, SkeletonText};
use crate::{audio::{AudioInputControl, start_audio_input}, prediction::{BandcampResult, PossibleResponse}, scene::{ConversationEntry, Scene, Scenery, StageActions, StageDirection}, transcription::TranscriptionControl, tts::{TtsControl, start_tts}};
use crate::{artifacts::archive::Archive, audio::start_audio_input, scene::{StageDirection, conversation::ConversationEntry}, sfx::start_sfx, tts::start_tts, ui::Ui};
mod scene;
mod events;
@@ -21,6 +18,10 @@ mod transcription;
mod tts;
mod prediction;
mod audio;
mod artifacts;
mod ui;
mod widgets;
mod sfx;
// TODO: We should be able to delete entries from the conversation, or at least go back and edit something I said.
// TODO: I want a "mark" command or keyboard shortcut, that inserts a marker into the log, so I know where to come back for the next speaking segment.
@@ -51,492 +52,37 @@ mod audio;
- Right panel: Shortcuts for triggering scenarios, soundboard events, etc
*/
impl<'a> Into<ListItem<'a>> for PossibleResponse {
fn into(self) -> ListItem<'a> {
if let Some(direction) = self.stage_direction {
Line::from_iter([
//Span::from(format!("({})", direction)).style(ratatui::style::Color::Yellow),
//Span::from(" "),
Span::from(self.text)
]).into()
} else {
Line::from(self.text).into()
}
}
}
#[derive(Debug)]
struct App {
scene: Scene,
direction: StageDirection,
next_actions: Vec<ConversationEntry>,
end_time: DateTime<Utc>,
reply_state: ListState,
conversation_state: ListState,
user_input: Input,
throbber_state: ThrobberState,
is_requesting: bool,
audio_level: f64,
recording_audio: bool,
focus_state: FocusState,
transcription: TranscriptionControl,
prediction_request_sink: watch::Sender<StageActions>,
audio: AudioInputControl,
tts: TtsControl,
sys_message_sink: mpsc::Sender<String>
}
#[derive(Debug)]
enum FocusState {
Conversation,
UserInput
}
impl App {
fn new(prediction_request_sink: watch::Sender<StageActions>, audio: AudioInputControl, transcription: TranscriptionControl, tts: TtsControl, sys_message_sink: mpsc::Sender<String>, initial_direction: StageDirection) -> Self {
Self {
scene: Default::default(),
direction: initial_direction,
next_actions: Default::default(),
reply_state: Default::default(),
conversation_state: Default::default(),
user_input: Default::default(),
end_time: Utc::now() + Duration::hours(2),
throbber_state: Default::default(),
prediction_request_sink,
is_requesting: false,
audio_level: -60.,
audio,
recording_audio: false,
transcription,
focus_state: FocusState::UserInput,
tts,
sys_message_sink
}
}
fn format_line<'a>(entry: &ConversationEntry, max_width: usize) -> Text<'a> {
let prefix = match entry {
ConversationEntry::Eva(_) => "Eva: ",
ConversationEntry::User(_) => "Argee: ",
ConversationEntry::ShipComputer(_) => "Ship Computer: ",
_ => "",
};
let style = match entry {
ConversationEntry::Eva(_) => Style::new().fg(style::Color::Cyan),
ConversationEntry::User(_) => Style::new().fg(style::Color::Magenta),
ConversationEntry::ShipComputer(_) => Style::new().fg(style::Color::Green),
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
};
let text_style = match entry {
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
_ => Style::new()
};
let text = match entry {
ConversationEntry::Eva(text) => text,
ConversationEntry::ShipComputer(text) => text,
ConversationEntry::StageDirection(text) => text,
ConversationEntry::SystemMessage(text) => text,
ConversationEntry::User(text) => text
};
let avail_width = max_width - prefix.len();
let indent = " ".repeat(prefix.len());
let wrap_options = textwrap::Options::new(avail_width).initial_indent(prefix).subsequent_indent(&indent);
let wrapped: Vec<Line> = textwrap::wrap(text, wrap_options)
.iter()
.enumerate()
.map(|(idx, s)| {
if idx == 0 {
Line::from_iter([Span::from(prefix).style(style), Span::from(s[prefix.len()..].to_string()).style(text_style)])
} else {
Line::from(s.to_string()).style(text_style)
}
}).collect();
Text::from_iter(wrapped)
}
fn draw_conversation(&mut self, frame: &mut Frame, area: Rect) {
let width = area.width.into();
let items: Vec<Text> = self.scene.conversation().iter().rev().map(|entry| {
Self::format_line(entry, width)
}).collect();
// TODO: Would be nice to be able to scroll a longer conversation with the scroll wheel, or with page up/down
frame.render_stateful_widget(
List::new(items)
.block(Block::bordered().border_style(style::Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)"))
.direction(ListDirection::BottomToTop)
.highlight_symbol("> ")
.highlight_style(style::Style::new().bold().fg(style::Color::Cyan)),
area,
&mut self.conversation_state
);
}
fn draw_options(&mut self, frame: &mut Frame, area: Rect) {
let borders = Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)");
if self.scene.reply_options().len() == 0 && self.is_requesting {
let list = SkeletonText::new(std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64)
.braille(true)
.line_widths(&[0.25, 0.5, 0.4, 0.6])
.mode(AnimationMode::Noise)
.block(borders);
frame.render_widget(list, area);
} else {
let wrap_options = textwrap::Options::new(area.width as usize).subsequent_indent("...");
let options: Vec<Text> = self.scene.reply_options().iter().map(|option| {
let mut contents: Vec<Line> = vec![];
if let Some(direction) = &option.stage_direction {
let padded = format!("({})", direction);
let mut wrapped_direction: Vec<Line> = textwrap::wrap(&padded, wrap_options.clone())
.iter()
.map(|x| { Line::from(x.to_string()).fg(style::Color::Yellow)}).collect();
contents.append(&mut wrapped_direction);
}
let mut text: Vec<Line> = textwrap::wrap(&option.text, wrap_options.clone())
.iter()
.map(|x| { Line::from(x.to_string())}).collect();
contents.append(&mut text);
Text::from_iter(contents)
}).collect();
frame.render_stateful_widget(
List::new(options)
.block(borders)
.style(ratatui::style::Color::White)
.highlight_symbol("> ".fg(Color::Cyan))
.highlight_style(style::Style::new().bold().bg(style::Color::DarkGray))
.repeat_highlight_symbol(true),
area,
&mut self.reply_state
);
}
}
fn draw_user_input(&mut self, frame: &mut Frame, area: Rect) {
let width = area.width.max(3) - 3;
let scroll = self.user_input.visual_scroll(width as usize);
let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input (Press 'Ctrl-X' to start/stop audio transcription)")).scroll((0, scroll as u16));
frame.render_widget(input, area);
let x = self.user_input.visual_cursor().max(scroll);
frame.set_cursor_position((area.x + x as u16 + 1, area.y + 1));
}
fn draw_io_throbber(&mut self, frame: &mut Frame, area: Rect) {
let throb_area = area.centered(Constraint::Max(1), Constraint::Max(1));
if self.is_requesting {
let throbber = Throbber::default();
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
} else {
frame.render_widget(Clear::default(), throb_area);
}
}
fn draw_status(&self, frame: &mut Frame, area: Rect) {
let minutes_remaining = self.direction.time_remaining.num_seconds() / 60;
let negative = self.direction.time_remaining.abs() != self.direction.time_remaining;
let time_style = if minutes_remaining <= 0 || negative {
Style::new().fg(ratatui::style::Color::LightRed).bold()
} else if minutes_remaining < 5 {
Style::new().fg(ratatui::style::Color::LightRed).bold()
} else if minutes_remaining < 10 {
ratatui::style::Color::Red.into()
} else if minutes_remaining < 25 {
ratatui::style::Color::Yellow.into()
} else if minutes_remaining < 60 {
ratatui::style::Color::Green.into()
} else {
ratatui::style::Color::Blue.into()
};
let formatted_time = if negative {
format!("-{:0>2}:{:0>2}:{:0>2}", self.direction.time_remaining.num_hours().abs(), self.direction.time_remaining.num_minutes().abs()% 60, self.direction.time_remaining.num_seconds().abs() % 60)
} else {
format!("{:0>2}:{:0>2}:{:0>2}", self.direction.time_remaining.num_hours(), self.direction.time_remaining.num_minutes() % 60, self.direction.time_remaining.num_seconds() % 60)
};
let status_line = Line::from_iter([
Span::from(format!("Episode {}", self.direction.episode_number)).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("{} tracks", self.direction.current_playlist.len())).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("Time Remaining: {}", formatted_time)).style(time_style),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("{} artifacts recorded", self.scene.scenery().artifacts.len())).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("{} tokens sacrificed", self.scene.tokens_consumed)).style(ratatui::style::Color::LightCyan),
]);
frame.render_widget(status_line, area);
}
fn draw_narration(&self, frame: &mut Frame, area: Rect) {
let narrative_desc = if self.direction.narrative.is_empty() {
Span::from("No narrative available.").style(ratatui::style::Color::DarkGray)
} else {
Span::from(self.direction.narrative.clone())
};
let setting = Paragraph::new(narrative_desc).block(Block::bordered().border_style(style::Color::LightMagenta).title("Stage Direction")).wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(setting, area);
}
fn draw_event_log(&self, frame: &mut Frame, area: Rect) {
let items: Vec<Line> = self.scene.conversation().iter().filter(|entry| { if let ConversationEntry::StageDirection(_) = entry { true } else { false }}).rev().map(|entry| {
match entry {
ConversationEntry::StageDirection(text) => Line::from_iter([text]).style(ratatui::style::Color::Yellow),
_ => unreachable!()
}
}).collect();
frame.render_widget(Paragraph::new(items).block(Block::bordered().border_style(style::Color::Red).title("Event Log")).wrap(Wrap { trim: false }), area);
}
fn draw(&mut self, frame: &mut Frame) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(3),
Constraint::Min(9),
Constraint::Max(3),
Constraint::Max(1)
])
.split(frame.area());
let scene_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(4), Constraint::Fill(1)])
.split(layout[0]);
let context_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Fill(1)])
.split(scene_layout[1]);
self.draw_conversation(frame, scene_layout[0]);
self.draw_narration(frame, context_layout[0]);
self.draw_event_log(frame, context_layout[1]);
self.draw_options(frame, layout[1]);
let status_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Max(3), Constraint::Fill(2), Constraint::Max(13), Constraint::Min(50)])
.split(layout[3]);
self.draw_user_input(frame, layout[2]);
self.draw_io_throbber(frame, status_layout[0]);
self.draw_status(frame, status_layout[1]);
self.draw_recording_status(frame, status_layout[2]);
self.draw_volume(frame, status_layout[3]);
}
fn draw_recording_status(&self, frame: &mut Frame, area: Rect) {
frame.render_widget(Line::from_iter(
if self.recording_audio {
[
Span::from(" "),
Span::from(" Recording ").bg(style::Color::LightRed).fg(style::Color::White),
Span::from(" ")
]
} else {
[
Span::from(" "),
Span::from(" Stopped ").bg(style::Color::DarkGray).fg(style::Color::White),
Span::from(" ")
]
}
), area);
}
fn draw_volume(&self, frame: &mut Frame, area: Rect) {
const NOISE_FLOOR: f64 = 50.;
let vu_pct = 1.0 - (self.audio_level.abs().min(NOISE_FLOOR) / NOISE_FLOOR);
let volume_color = if self.recording_audio {
if vu_pct >= 0.85 {
style::Color::Red
} else if vu_pct >= 0.60 {
style::Color::Yellow
} else {
style::Color::LightGreen
}
} else {
style::Color::Gray
};
let gauge = Gauge::default()
.ratio(vu_pct)
.use_unicode(true)
.gauge_style(volume_color)
.label(format!("{:.01}dB", self.audio_level));
frame.render_widget(gauge, area);
}
async fn insert_selected_prompt(&mut self) {
let selected = self.scene.reply_options()[self.reply_state.selected().unwrap()].clone();
if let Some(direction) = &selected.stage_direction {
self.next_actions.push(ConversationEntry::StageDirection(direction.clone()));
}
self.next_actions.push(ConversationEntry::Eva(selected.text.clone()));
self.tts.speak(selected.text.clone()).await;
self.regenerate_responses();
}
async fn on_command(&mut self, command: &str) {
let mut parts = command.splitn(2, " ");
let command = parts.next().unwrap();
let arg = parts.next().unwrap_or("");
match command {
// FIXME: Need some new kind of /bandcamp command to force loading of specific urls
"/episode" => {
if let Ok(episode_number) = arg.trim().parse::<u32>() {
self.direction.episode_number = episode_number;
self.sys_message_sink.send(format!("Updated episode number: {}", self.direction.episode_number)).await.unwrap();
self.reload_mixxx_playlist();
} else {
self.sys_message_sink.send("Invalid episode number format. Use /episode [number]".into()).await.unwrap();
return;
}
},
"/timer" => {
if let Ok(minutes) = arg.trim().parse::<i64>() {
self.end_time = Utc::now() + Duration::minutes(minutes);
self.sys_message_sink.send(format!("Set timer for {} minutes.", minutes)).await.unwrap();
} else {
self.sys_message_sink.send("Invalid timer format. Use /timer [minutes]".into()).await.unwrap();
}
},
"/narrative" => {
self.direction.narrative = arg.to_string();
self.sys_message_sink.send(format!("Updated stage direction: {}", self.direction.narrative)).await.unwrap();
self.regenerate_responses();
},
"/event" => {
self.next_actions.push(ConversationEntry::StageDirection(arg.to_string()));
self.regenerate_responses();
},
"/computer" => {
self.next_actions.push(ConversationEntry::ShipComputer(arg.to_string()));
self.regenerate_responses();
},
_ => {
self.sys_message_sink.send("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]".into()).await.unwrap();
}
}
}
async fn on_event(&mut self, evt: event::Event) {
if let Some(key) = evt.as_key_press_event() {
match self.focus_state {
FocusState::Conversation => {
match key.code {
KeyCode::Tab => {
self.focus_state = FocusState::UserInput;
self.conversation_state.select(None);
self.reply_state.select_first();
},
KeyCode::PageUp => self.conversation_state.scroll_down_by(5),
KeyCode::PageDown => self.conversation_state.scroll_up_by(5),
KeyCode::Home => self.conversation_state.select_last(),
KeyCode::End => self.conversation_state.select_first(),
KeyCode::Down => self.conversation_state.select_previous(),
KeyCode::Up => self.conversation_state.select_next(),
KeyCode::Enter => {
let row_num = self.conversation_state.selected().unwrap();
if let ConversationEntry::Eva(text) = &self.scene.conversation()[self.scene.conversation().len() - 1 - row_num] {
self.tts.speak(text.clone()).await;
self.focus_state = FocusState::UserInput;
self.conversation_state.select(None);
self.reply_state.select_first();
}
},
_ => ()
}
},
FocusState::UserInput => {
match key.code {
KeyCode::Tab => {
self.focus_state = FocusState::Conversation;
self.conversation_state.select_first();
self.reply_state.select(None);
},
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.regenerate_responses(),
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.recording_audio {
self.recording_audio = false;
self.transcription.stop();
self.is_requesting = true;
} else {
self.recording_audio = true;
self.transcription.start();
}
},
KeyCode::Down => self.reply_state.select_next(),
KeyCode::Up => self.reply_state.select_previous(),
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.insert_selected_prompt().await;
},
KeyCode::Enter => {
let next_msg = self.user_input.value_and_reset();
if next_msg.trim().is_empty() {
self.insert_selected_prompt().await;
} else {
if next_msg.starts_with("/") {
self.on_command(&next_msg).await;
} else {
self.next_actions.push(ConversationEntry::User(next_msg));
self.regenerate_responses();
}
}
},
_ => {self.user_input.handle_event(&evt);},
}
}
}
}
}
fn regenerate_responses(&mut self) {
let actions = StageActions {
direction: self.direction.clone(),
additions: std::mem::take(&mut self.next_actions)
};
self.scene.reply_options_mut().clear();
self.scene.conversation_mut().append(&mut actions.additions.clone());
self.prediction_request_sink.send(actions).unwrap();
self.is_requesting = true;
}
fn reload_mixxx_playlist(&mut self) {
if let Err(err) = self.direction.reload_mixxx_playlist() {
self.next_actions.push(ConversationEntry::SystemMessage(format!("Error while loading mixxx playlist: {:?}", err)));
} else {
self.next_actions.push(ConversationEntry::SystemMessage(format!("Mixxx playlist reloaded. {} tracks found.", self.direction.current_playlist.len()).into()));
}
}
}
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct SaveData {
pub direction: StageDirection,
pub messages: Vec<ChatCompletionRequestMessage>,
pub scenery: Scenery
pub tokens_consumed: usize,
pub archive: Archive
}
impl SaveData {
fn save(&self) {
fn save(&self) -> Result<(), std::io::Error> {
let save_data = serde_json::to_string_pretty(self).unwrap();
std::fs::write("save.json", save_data).unwrap();
std::fs::write("save.json", save_data)
}
}
struct SysMessageLogger<T>(Arc<tokio::sync::mpsc::UnboundedSender<ConversationEntry>>, Mutex<T>);
impl<T: std::io::Write + Send + Sync> log::Log for SysMessageLogger<T> {
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
fn flush(&self) {}
fn log(&self, record: &log::Record) {
let msg = format!("{}", record.args());
writeln!(self.1.lock().unwrap(), "{}", msg).unwrap();
if record.level() <= LevelFilter::Info {
self.0.send(ConversationEntry::SystemMessage(msg)).unwrap();
}
}
}
@@ -549,10 +95,18 @@ async fn main() {
eyre_hook.install().unwrap();
std::panic::set_hook(Box::new(move |panic_info| {
ratatui::restore();
let msg = format!("{}", panic_hook.panic_report(panic_info));
println!("Panic: {}", msg);
}));
let (conversation_sink, conversation_src) = tokio::sync::mpsc::unbounded_channel();
static LOGGER: StaticCell<SysMessageLogger<std::fs::File>> = StaticCell::new();
let logger = LOGGER.init(SysMessageLogger(Arc::new(conversation_sink), Mutex::new(std::fs::File::create("out.log").unwrap())));
log::set_logger(logger).unwrap();
log::set_max_level(log::LevelFilter::Debug);
dotenv::dotenv().ok();
if std::env::var("OPENAI_API_KEY").is_err() {
@@ -562,57 +116,39 @@ async fn main() {
let mut terminal: Terminal<CrosstermBackend<std::io::Stdout>> = ratatui::init();
let (sys_message_sink, sys_message_src) = tokio::sync::mpsc::channel(32);
let saved_session = if let Ok(save_data) = std::fs::read_to_string("save.json") {
if let Ok(ret) = serde_json::from_str(&save_data) {
sys_message_sink.send("Loaded session from save.json".into()).await.unwrap();
match serde_json::from_str(&save_data) {
Ok(ret) => {
log::info!("Loaded session from save.json");
ret
} else {
sys_message_sink.send("Could not load saved session!".into()).await.unwrap();
},
Err(err) => {
log::error!("Could not load saved session! {:?}", err);
SaveData::default()
}
}
} else {
sys_message_sink.send("Creating new session in save.json".into()).await.unwrap();
log::info!("Creating new session in save.json");
SaveData::default()
};
let (audio_ctrl, mic_stream, tts_output) = start_audio_input(&sys_message_sink).await;
let (audio_ctrl, mic_stream, tts_output, sfx_output) = start_audio_input().await;
let tts_ctrl = start_tts(tts_output).await;
let (prediction_request_in, mut prediction_out) = prediction::start_prediction(sys_message_src, saved_session.messages, saved_session.scenery).await;
let sfx_ctrl = start_sfx(sfx_output).await;
let transcription_ctrl = transcription::start_transcription(mic_stream).await;
let mut app = App::new(prediction_request_in, audio_ctrl, transcription_ctrl, tts_ctrl, sys_message_sink, saved_session.direction);
let prediction_ctrl = prediction::conversation_task(saved_session, conversation_src, sfx_ctrl.clone()).await;
let mut app = Ui::new(prediction_ctrl, audio_ctrl, transcription_ctrl, tts_ctrl, sfx_ctrl);
let mut events = EventStream::new();
let mut last_tick = Instant::now();
loop {
if last_tick.elapsed() >= std::time::Duration::from_millis(100) {
last_tick = Instant::now();
app.throbber_state.calc_next();
}
app.direction.time_remaining = app.end_time.signed_duration_since(Utc::now());
terminal.draw(|frame| { app.draw(frame)}).unwrap();
let delay = Delay::new(std::time::Duration::from_millis(60)).fuse();
let event = events.next().fuse();
tokio::select! {
_ = delay => (),
_ = prediction_out.changed() => {
app.scene = prediction_out.borrow().clone();
app.reply_state.select_first();
app.is_requesting = false;
},
next_volume = app.audio.next() => {
app.audio_level = next_volume
},
transcription_result = app.transcription.next() => {
app.next_actions.push(ConversationEntry::User(transcription_result));
app.regenerate_responses();
},
maybe_event = event => {
_ = app.update() => {},
maybe_event = events.next() => {
match maybe_event {
Some(Ok(event)) => {
@@ -624,7 +160,7 @@ async fn main() {
app.on_event(event).await;
},
_ => ()
_ => break
}
}
};
-387
View File
@@ -1,387 +0,0 @@
use std::process::{Command, Stdio};
use async_openai::{Client, config::OpenAIConfig, types::chat::{ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestToolMessageArgs, ChatCompletionTool, ChatCompletionTools, CreateChatCompletionRequestArgs, FinishReason, FunctionObjectArgs, ResponseFormat, ResponseFormatJsonSchema}};
use bandcamp::SearchResultItem;
use chrono::{DateTime, Utc};
use color_eyre::eyre::eyre;
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use serde_json::{Serializer, ser::CompactFormatter};
use crate::{SaveData, scene::{Artifact, ConversationEntry, Scene, Scenery, StageActions, StageDirection}};
const SYSTEM_PROMPT: &str = include_str!("system-prompt.txt");
#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
pub struct PossibleResponse {
pub text: String,
pub stage_direction: Option<String>
}
#[derive(Default, Debug, JsonSchema, Deserialize, Serialize, Clone)]
pub struct GeneratedResponses {
pub responses: Vec<PossibleResponse>,
}
#[derive(Debug)]
struct Session {
client: Client<OpenAIConfig>,
conversation: Vec<ConversationEntry>,
header_message: ChatCompletionRequestMessage,
messages: Vec<ChatCompletionRequestMessage>,
reply_options: GeneratedResponses,
scenery: Scenery,
tokens_consumed: usize
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
enum StageEvent {
ShipComputer(String),
StageDirection(String)
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
struct StageEventArgs {
event: StageEvent
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
struct BeatsQueryArgs {
artist: Option<String>,
album: Option<String>,
genre: Option<String>,
title: Option<String>,
year: Option<u32>
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
struct BandcampQueryArgs {
query: String
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum BandcampResult {
Artist { name: String, bio: Option<String>, location: Option<String> },
Album { title: String, about: Option<String>, credits: Option<String>, release_date: DateTime<Utc>, artist: String }
}
impl Into<BandcampResult> for bandcamp::Artist {
fn into(self) -> BandcampResult {
BandcampResult::Artist { name: self.name, bio: self.bio, location: self.location }
}
}
impl Into<BandcampResult> for bandcamp::Album {
fn into(self) -> BandcampResult {
BandcampResult::Album {
about: self.about,
title: self.title,
artist: self.band.name,
credits: self.credits,
release_date: self.release_date
}
}
}
#[derive(Default, Debug)]
struct ToolResults {
result: Option<String>,
messages: Vec<ConversationEntry>
}
impl Session {
fn from_initial_messages(messages: Vec<ChatCompletionRequestMessage>, scenery: Scenery) -> Self {
let mut conversation = vec![];
for msg in &messages {
if let Ok(conversation_msg) = msg.clone().try_into() {
conversation.push(conversation_msg);
}
}
Self {
client: Default::default(),
conversation,
header_message: ChatCompletionRequestSystemMessageArgs::default().content(SYSTEM_PROMPT).build().unwrap().into(),
messages,
reply_options: Default::default(),
scenery,
tokens_consumed: 0
}
}
fn insert_actions(&mut self, actions: &StageActions) {
for addition in &actions.additions {
self.insert_conversation(addition.clone());
}
}
async fn tool_stage_event(&mut self, args: StageEventArgs) -> ToolResults {
let msg = match args.event {
StageEvent::ShipComputer(text) => ConversationEntry::ShipComputer(text),
StageEvent::StageDirection(text) => ConversationEntry::StageDirection(text)
};
ToolResults {
messages: vec![msg],
..Default::default()
}
}
async fn tool_bandcamp_scan(&mut self, args: BandcampQueryArgs) -> ToolResults {
let mut messages = vec![];
messages.push(ConversationEntry::SystemMessage(format!("Fetching artifacts from Bandcamp with {:?}", args).into()));
let mut json_results = vec![];
if let Ok(results) = bandcamp::search(args.query.as_str()).await {
for result in results {
match result {
SearchResultItem::Artist(data) => {
let result: BandcampResult = bandcamp::fetch_artist(data.artist_id).await.unwrap().into();
json_results.push(Artifact::Bandcamp(result));
},
SearchResultItem::Album(data) => {
let result: BandcampResult = bandcamp::fetch_album(data.band_id, data.album_id).await.unwrap().into();
json_results.push(Artifact::Bandcamp(result));
}
_ => ()
}
}
}
let artifact_count = json_results.len();
messages.push(ConversationEntry::ShipComputer(format!("Relay scan for '{}' complete. {} artifacts added to the archive.", args.query, artifact_count).into()));
self.scenery.artifacts.append(&mut json_results);
ToolResults {
result: Some(format!("{} artifacts were added to the archive.", artifact_count)),
messages
}
}
async fn tool_artifact_query(&mut self, args: BeatsQueryArgs) -> ToolResults {
let mut messages = vec![];
let mut beets_cmd = Command::new("beet");
beets_cmd.args(["export", "-f", "json", "-i", "title,label,year,genres,album,artist"]);
if let Some(artist) = args.artist {
beets_cmd.arg(format!("artist:{}", artist));
}
if let Some(genre) = args.genre {
beets_cmd.arg(format!("genre:{}", genre));
}
if let Some(album) = args.album {
beets_cmd.arg(format!("album:{}", album));
}
if let Some(title) = args.title {
beets_cmd.arg(format!("title:{}", title));
}
if let Some(year) = args.year {
beets_cmd.arg(format!("year:{}", year));
}
if let Ok(output) = beets_cmd.stdout(Stdio::piped()).spawn().unwrap().wait_with_output() {
messages.push(ConversationEntry::ShipComputer(format!("Executing archive query {:?}", beets_cmd)));
self.scenery.artifacts.push(Artifact::BeetsTrack(serde_json::from_str(str::from_utf8(&output.stdout).unwrap()).unwrap()));
} else {
messages.push(ConversationEntry::ShipComputer("Unable to execute query!".into()));
};
ToolResults {
result: None,
messages
}
}
fn generate_conversation(&self, direction: &StageDirection) -> Vec<ChatCompletionRequestMessage> {
let mut json_buf = vec![];
let mut ser = Serializer::with_formatter(&mut json_buf, CompactFormatter);
direction.serialize(&mut ser).unwrap();
let direction_message: ChatCompletionRequestMessage = ChatCompletionRequestSystemMessageArgs::default()
.content(String::from_utf8(json_buf).unwrap())
.build().unwrap().into();
let mut json_buf = vec![];
let mut ser = Serializer::with_formatter(&mut json_buf, CompactFormatter);
self.scenery.serialize(&mut ser).unwrap();
let scenery_message: ChatCompletionRequestMessage = ChatCompletionRequestSystemMessageArgs::default()
.content(String::from_utf8(json_buf).unwrap())
.build().unwrap().into();
let mut full_conversation = vec![
self.header_message.clone(),
direction_message,
scenery_message,
];
full_conversation.append(&mut self.messages.clone());
full_conversation
}
async fn regenerate_options(&mut self, direction: &StageDirection) {
loop {
let full_conversation = self.generate_conversation(direction);
let tools = vec![
ChatCompletionTools::Function(ChatCompletionTool {
function: FunctionObjectArgs::default()
.name("log_stage_event")
.description("Inserts an event into the current scene script")
.parameters(schema_for!(StageEventArgs))
.build().unwrap()
}),
ChatCompletionTools::Function(ChatCompletionTool {
function: FunctionObjectArgs::default()
.name("archive_query")
.description("Queries the ship's musical artifact archives for tracks matching the given search parameters")
.parameters(schema_for!(BeatsQueryArgs))
.build().unwrap()
}),
ChatCompletionTools::Function(ChatCompletionTool {
function: FunctionObjectArgs::default()
.name("bandcamp_artifact_scan")
.description("Scans Bandcamp to find artifacts to use in the scene that match the given search parameters. To find an artist, provide only the artist name. To find an album, provide the artist and the album.")
.parameters(schema_for!(BandcampQueryArgs))
.build().unwrap()
})
];
let request = CreateChatCompletionRequestArgs::default()
.messages(full_conversation)
.model("gpt-5.4-mini")
.tools(tools)
.max_completion_tokens(1024u32)
.response_format(ResponseFormat::JsonSchema {
json_schema: ResponseFormatJsonSchema {
description: None,
name: "responses".into(),
schema: schema_for!(GeneratedResponses).into(),
strict: None
}
})
.build().unwrap();
let response = self.client.chat().create(request).await.unwrap_or_else(|err| {
panic!("OpenAI Panic: {}", err);
});
if let Some(usage) = response.usage {
self.tokens_consumed += usage.total_tokens as usize;
}
if let Some(message) = response.choices.first() {
match message.finish_reason {
Some(FinishReason::ContentFilter) => {
self.insert_conversation(ConversationEntry::SystemMessage("Content filter triggered.".into()));
return;
},
Some(FinishReason::Length) => {
self.insert_conversation(ConversationEntry::SystemMessage("Maximum token count exceeded!".into()));
return;
},
_ => ()
}
if let Some(calls) = &message.message.tool_calls {
let assistant_messages: ChatCompletionRequestMessage = ChatCompletionRequestAssistantMessageArgs::default()
.tool_calls(calls.clone())
.build().unwrap().into();
self.messages.push(assistant_messages);
let mut results = vec![];
for call in calls {
match call {
ChatCompletionMessageToolCalls::Function(call) => {
let func_name = call.function.name.as_str();
let args = call.function.arguments.as_str();
let tool_result = match func_name {
"log_stage_event" => self.tool_stage_event(serde_json::from_str(args).unwrap()).await,
"bandcamp_artifact_scan" => self.tool_bandcamp_scan(serde_json::from_str(args).unwrap()).await,
"archive_query" => self.tool_artifact_query(serde_json::from_str(args).unwrap()).await,
_ => unreachable!()
};
results.push((&call.id, tool_result));
},
_ => panic!("Unknown tool was called")
}
}
let mut tool_messages = vec![];
for (id, mut result) in results {
let mut msg = ChatCompletionRequestToolMessageArgs::default();
msg.tool_call_id(id);
if let Some(output) = result.result {
msg.content(output);
}
self.messages.push(ChatCompletionRequestMessage::Tool(msg.build().unwrap()));
tool_messages.append(&mut result.messages);
}
for msg in tool_messages {
self.insert_conversation(msg);
}
}
if let Some(content) = message.message.content.as_ref() {
if let Ok(options) = serde_json::from_str(content.as_str()) {
self.reply_options = options;
return;
} else {
self.insert_conversation(ConversationEntry::SystemMessage("Received invalid JSON! Trying again.".into()));
}
}
} else {
self.insert_conversation(ConversationEntry::SystemMessage("No messages were received! Trying again.".into()));
}
}
}
fn as_scene(&self) -> Scene {
Scene::new(self.reply_options.clone(), self.conversation.clone(), self.scenery.clone(), self.tokens_consumed)
}
fn insert_conversation(&mut self, entry: ConversationEntry) {
self.conversation.push(entry.clone());
if let Ok(next_msg) = entry.try_into() {
self.messages.push(next_msg);
}
}
}
pub async fn start_prediction(mut sys_message_src: tokio::sync::mpsc::Receiver<String>, initial_messages: Vec<ChatCompletionRequestMessage>, scenery: Scenery) -> (tokio::sync::watch::Sender<StageActions>, tokio::sync::watch::Receiver<Scene>) {
let (prediction_in, prediction_out) = tokio::sync::watch::channel(Scene::default());
let (prediction_request_in, mut prediction_request_out) = tokio::sync::watch::channel(StageActions::default());
let mut session = Session::from_initial_messages(initial_messages, scenery);
// Send the initial scene to the UI, after we have loaded the session from the first messages.
prediction_in.send(session.as_scene()).unwrap();
tokio::spawn(async move {
loop {
tokio::select! {
maybe_message = sys_message_src.recv() => {
if let Some(message) = maybe_message {
session.insert_conversation(ConversationEntry::SystemMessage(message));
prediction_in.send(session.as_scene()).unwrap();
}
},
maybe_request = prediction_request_out.changed() => {
if maybe_request.is_ok() {
let next_cxt = prediction_request_out.borrow().clone();
session.insert_actions(&next_cxt);
let mut save_data = SaveData {
direction: next_cxt.direction,
messages: session.messages.clone(),
scenery: session.scenery.clone()
};
save_data.save();
session.regenerate_options(&save_data.direction).await;
save_data.messages = session.messages.clone();
save_data.save();
prediction_in.send(session.as_scene()).unwrap();
}
}
};
}
});
(prediction_request_in, prediction_out)
}
+242
View File
@@ -0,0 +1,242 @@
use async_openai::{Client, config::OpenAIConfig, error::OpenAIError, types::chat::{ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage, ChatCompletionRequestToolMessageArgs, CreateChatCompletionRequestArgs, FinishReason, ResponseFormat, ResponseFormatJsonSchema}};
use serde_json::Value;
use tokio::sync::mpsc::{self, UnboundedSender};
use crate::prediction::{PredictionAction, toolbox::{ToolResults, Toolbox}};
#[derive(Debug, Clone)]
enum CharacterInput {
Append(ChatCompletionRequestMessage),
Predict(ChatCompletionRequestMessage),
Forget
}
#[derive(Debug, Clone)]
pub enum CharacterOutput {
Response(usize, Value),
IncrementalResponse(usize),
Thinking(bool)
}
#[derive(Debug)]
pub enum CharacterError {
OpenAI(OpenAIError),
ContentFilter,
MaxTokens,
NoOutput,
Json(serde_json::Error)
}
impl From<OpenAIError> for CharacterError {
fn from(value: OpenAIError) -> Self {
Self::OpenAI(value)
}
}
impl From<serde_json::Error> for CharacterError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
#[derive(Debug)]
pub struct Character {
pub header_message: ChatCompletionRequestMessage,
pub model: Option<String>,
pub messages: Vec<ChatCompletionRequestMessage>,
}
impl Character {
pub async fn regenerate<T: Toolbox>(&mut self, client: &mut Client<OpenAIConfig>, context: ChatCompletionRequestMessage, toolbox: &mut T, output: &mut mpsc::UnboundedSender<PredictionAction>, schema: &Value) -> Result<(usize, Option<Value>), CharacterError> {
let mut full_conversation = vec![
self.header_message.clone(),
];
full_conversation.extend(self.messages.iter().cloned());
full_conversation.push(context);
let tools = toolbox.tools();
log::debug!("Sending request..");
let request = CreateChatCompletionRequestArgs::default()
.messages(full_conversation)
.model(self.model.clone().unwrap_or("gpt-5.4-mini".into()))
.tools(tools)
.max_completion_tokens(1024u32)
.response_format(ResponseFormat::JsonSchema {
json_schema: ResponseFormatJsonSchema {
description: None,
name: "responses".into(),
schema: schema.clone(),
strict: None
}
})
.build().unwrap();
let response = client.chat().create(request).await?;
let tokens_used = if let Some(usage) = response.usage {
let cached_count = usage.prompt_tokens_details.map(|x| x.cached_tokens.unwrap_or_default()).unwrap_or_default();
let prompt_count = usage.prompt_tokens;
let cache_hit_rate = cached_count as f32 / prompt_count as f32;
log::debug!("{} tokens cast into the void (%{:#.02} cache hit)", usage.total_tokens, cache_hit_rate*100.);
usage.total_tokens
} else {
0
};
if let Some(message) = response.choices.first() {
match message.finish_reason {
Some(FinishReason::ContentFilter) => {
log::error!("Content filter triggered.");
return Err(CharacterError::ContentFilter);
},
Some(FinishReason::Length) => {
log::error!("Maximum token count exceeded!");
return Err(CharacterError::MaxTokens);
},
_ => ()
}
if let Some(calls) = &message.message.tool_calls {
let assistant_messages: ChatCompletionRequestMessage = ChatCompletionRequestAssistantMessageArgs::default()
.tool_calls(calls.clone())
.build().unwrap().into();
self.insert(assistant_messages);
let mut results = vec![];
for (idx, call) in calls.iter().enumerate() {
match call {
ChatCompletionMessageToolCalls::Function(call) => {
log::debug!("Tool {} {}/{}", call.function.name, idx+1, calls.len());
log::debug!("Args {}", call.function.arguments);
match toolbox.execute_tool(call).await {
Ok(tool_result) => {
// Push tool output messages directly into the conversation as fast as we can
for message in &tool_result.messages {
output.send(PredictionAction::ConversationAppend(message.clone())).unwrap();
}
results.push((&call.id, tool_result));
},
Err(err) => {
results.push((&call.id, ToolResults {
result: Some(format!("Error while calling tool: {:?}", err)),
..Default::default()
}));
log::error!("Attemped to call {:?}, but got an error instead: {:?}", call, err);
}
}
},
_ => panic!("Unknown tool was called")
}
}
let mut tool_messages = vec![];
for (id, result) in results {
let mut msg = ChatCompletionRequestToolMessageArgs::default();
msg.tool_call_id(id);
if let Some(output) = result.result {
log::debug!("Output: {}", output);
msg.content(output);
}
self.insert(ChatCompletionRequestMessage::Tool(msg.build().unwrap()));
tool_messages.extend(result.messages);
}
// OpenAI requires we put all the tool call results before any other message, so we append them manually down here
for message in tool_messages {
if let Ok(next_msg) = message.clone().try_into() {
self.insert(next_msg);
}
}
}
if let Some(content) = message.message.content.as_ref() {
let options = serde_json::from_str(content.as_str())?;
Ok((tokens_used as usize, Some(options)))
} else {
Ok((tokens_used as usize, None))
}
} else {
Err(CharacterError::NoOutput)
}
}
pub fn insert(&mut self, message: ChatCompletionRequestMessage) {
self.messages.push(message);
}
}
pub struct CharacterControl {
sink: tokio::sync::mpsc::Sender<CharacterInput>,
outputs: tokio::sync::mpsc::Receiver<CharacterOutput>
}
impl CharacterControl {
async fn send(&mut self, message: CharacterInput) {
self.sink.send(message).await.unwrap()
}
pub async fn append(&mut self, message: ChatCompletionRequestMessage) {
self.send(CharacterInput::Append(message)).await;
}
pub async fn predict<T: Into<ChatCompletionRequestMessage>>(&mut self, context: T) {
self.send(CharacterInput::Predict(context.into())).await;
}
pub async fn recv(&mut self) -> CharacterOutput {
self.outputs.recv().await.unwrap()
}
pub async fn forget(&mut self) {
self.send(CharacterInput::Forget).await;
}
}
pub async fn character_task<T: Toolbox + 'static + Send>(mut char: Character, mut toolbox: T, mut message_sink: UnboundedSender<PredictionAction>, schema: Value) -> CharacterControl {
let (input_sink, mut input_src) = tokio::sync::mpsc::channel(3);
let (output_sink, output_src) = tokio::sync::mpsc::channel(3);
let ret = CharacterControl { sink: input_sink, outputs: output_src };
tokio::spawn(async move {
let mut client = Default::default();
loop {
if let Some(next_msg) = input_src.recv().await {
log::debug!("Character receive message {:?}", next_msg);
match next_msg {
CharacterInput::Append(next_msg) => {
log::debug!("Inserting to backlog");
char.insert(next_msg);
},
CharacterInput::Predict(context) => {
log::debug!("Predicting...");
output_sink.send(CharacterOutput::Thinking(true)).await.unwrap();
match char.regenerate(&mut client, context, &mut toolbox, &mut message_sink, &schema).await {
Ok((tokens_used, Some(response))) => {
log::debug!("Complete response: {:?}", response);
output_sink.send(CharacterOutput::Response(tokens_used, response)).await.unwrap();
output_sink.send(CharacterOutput::Thinking(false)).await.unwrap();
},
Ok((tokens_used, None)) => {
log::debug!("Incremental response");
output_sink.send(CharacterOutput::IncrementalResponse(tokens_used)).await.unwrap();
},
Err(err) => {
log::error!("Error while predicting: {:?}", err);
output_sink.send(CharacterOutput::Thinking(false)).await.unwrap();
}
}
},
CharacterInput::Forget => {
log::debug!("Wiping conversation backlog");
char.messages.clear();
}
}
} else {
return;
}
}
});
ret
}
+358
View File
@@ -0,0 +1,358 @@
use std::{collections::HashMap, fmt::Debug, sync::Arc};
use async_openai::types::chat::{ChatCompletionRequestMessage, ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs, };
use chrono::{DateTime, Utc};
use futures::stream::FuturesUnordered;
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use serde_json::{Serializer, Value, ser::CompactFormatter};
use tokio::sync::{Mutex, mpsc::{self, UnboundedReceiver, UnboundedSender}};
use crate::{SaveData, artifacts::{Contents, Track, archive::Archive, beets::{BeetsDB, BeetsFieldQuery, BeetsQueryArgs}, mixxx::{MixxxDB, MixxxQuery}, tools::DataSource}, prediction::{character::{Character, CharacterControl, CharacterOutput, character_task}, toolbox::{ArchiveToolbox, StageToolbox}}, scene::{Scene, StageDirection, conversation::{ConversationEntry, Speaker}}, sfx::SfxControl};
use tokio_stream::StreamExt;
pub mod character;
pub mod toolbox;
const SYSTEM_PROMPT: &str = include_str!("../system-prompt.txt");
const COMPUTER_PROMPT: &str = include_str!("../computer-prompt.txt");
#[derive(Debug, Clone)]
pub enum PredictionAction {
ConversationAppend(ConversationEntry),
ComputerCommand(String),
SetPlaylist(String),
GeneratePredictions,
SetNarrative(String),
SetShowEndTime(DateTime<Utc>)
}
#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
pub struct PossibleResponse {
pub text: String,
pub stage_direction: Option<String>
}
#[derive(Default, Debug, JsonSchema, Deserialize, Serialize, Clone)]
pub struct GeneratedResponses {
pub responses: Vec<PossibleResponse>,
}
#[derive(Serialize, Deserialize, JsonSchema, Debug, Default, Clone)]
struct ComputerResponse {
message: String,
finished: Option<bool>
}
#[derive(Debug)]
pub struct SessionControl {
event_sink: mpsc::UnboundedSender<PredictionAction>,
event_src: mpsc::UnboundedReceiver<SessionUpdate>
}
#[derive(Debug)]
pub enum SessionUpdate {
Scene(Scene),
Thinking(Speaker, bool),
Conversation(Vec<ConversationEntry>),
Responses(GeneratedResponses)
}
impl SessionControl {
pub async fn insert(&self, action: PredictionAction) {
self.event_sink.send(action).unwrap();
}
pub async fn regenerate_options(&self) {
self.insert(PredictionAction::GeneratePredictions).await;
}
pub async fn changed(&mut self) -> SessionUpdate {
self.event_src.recv().await.expect("Session closed")
}
}
struct Conversation {
characters: HashMap<Speaker, CharacterControl>,
event_sink: UnboundedSender<SessionUpdate>,
input_src: UnboundedReceiver<PredictionAction>,
backlog: Vec<ConversationEntry>,
eva_backlog: Vec<ChatCompletionRequestMessage>,
tokens_consumed: usize,
direction: StageDirection,
computer_todo: Arc<Mutex<HashMap<String, bool>>>,
archive: Arc<Mutex<Archive>>,
current_playlist: Vec<Track>,
sys_log_messages: UnboundedReceiver<ConversationEntry>,
sfx: SfxControl
}
impl Conversation {
async fn send_to(&mut self, speaker: Speaker, message: ChatCompletionRequestMessage) {
log::debug!("Sending message to {:?}: {:?}", speaker, message);
self.characters.get_mut(&speaker).unwrap().append(message).await;
}
async fn insert(&mut self, entry: ConversationEntry) {
self.backlog.push(entry.clone());
self.event_sink.send(SessionUpdate::Conversation(self.backlog.clone())).unwrap();
match entry {
ConversationEntry::Spoken(speaker, _) => {
match speaker {
Speaker::User => (),
_ => self.sfx.play_ambient().await.unwrap(),
}
if let Ok(next_msg) = TryInto::<ChatCompletionRequestMessage>::try_into(entry) {
self.send_to(Speaker::Eva, next_msg.clone()).await;
let cxt = self.context_for_speaker(Speaker::Eva).await;
self.characters.get_mut(&Speaker::Eva).unwrap().predict(cxt).await;
self.eva_backlog.push(next_msg);
}
self.event_sink.send(SessionUpdate::Thinking(Speaker::Eva, true)).unwrap();
},
ConversationEntry::ShipComputerCommand(ref command) => {
log::debug!("Queued ship computer command: {:?}", command);
self.send_to(Speaker::ShipComputer, ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessageArgs::default()
.content(command.clone())
.build().unwrap()
)).await;
let cxt = self.context_for_speaker(Speaker::ShipComputer).await;
self.characters.get_mut(&Speaker::ShipComputer).unwrap().predict(cxt).await;
self.event_sink.send(SessionUpdate::Thinking(Speaker::ShipComputer, true)).unwrap();
},
_ => ()
}
}
async fn get_character_output(characters: &mut HashMap<Speaker, CharacterControl>) -> Option<(Speaker, CharacterOutput)> {
let mut futures: FuturesUnordered<_> =
characters.iter_mut().map(|c| {
async { (*c.0, c.1.recv().await) }
}).collect();
futures.next().await
}
async fn context_for_speaker(&self, speaker: Speaker) -> ChatCompletionRequestMessage {
let mut json_buf = vec![];
let mut ser = Serializer::with_formatter(&mut json_buf, CompactFormatter);
let contents = match speaker {
Speaker::ShipComputer => serde_json::json!({
"archive": *self.archive.lock().await,
"playlist": &self.current_playlist,
"todo": *self.computer_todo.lock().await
}),
Speaker::Eva => serde_json::json!({
"direction": &self.direction,
"archive": *self.archive.lock().await,
"playlist": &self.current_playlist
}),
_ => unimplemented!()
};
contents.serialize(&mut ser).unwrap();
ChatCompletionRequestSystemMessageArgs::default()
.content(String::from_utf8(json_buf).unwrap())
.build().unwrap().into()
}
async fn process_dialog(&mut self, speaker: Speaker, value: Value) {
match speaker {
Speaker::Eva => {
// TODO: Handle crash from bad json
let next_options = serde_json::from_value(value).unwrap_or_default();
self.event_sink.send(SessionUpdate::Responses(next_options)).unwrap();
},
Speaker::ShipComputer => {
let response: ComputerResponse = serde_json::from_value(value).unwrap();
self.insert(ConversationEntry::Spoken(Speaker::ShipComputer, response.message)).await;
if response.finished.unwrap_or_default() {
self.characters.get_mut(&Speaker::ShipComputer).unwrap().forget().await;
if !self.computer_todo.lock().await.iter().any(|(_, is_finished)| !*is_finished) {
self.insert(ConversationEntry::StageDirection("The ship computer goes idle.".into())).await;
self.event_sink.send(SessionUpdate::Thinking(Speaker::ShipComputer, false)).unwrap();
} else {
self.insert(ConversationEntry::StageDirection("The ship computer starts another task.".into())).await;
let cxt = self.context_for_speaker(Speaker::ShipComputer).await;
self.characters.get_mut(&Speaker::ShipComputer).unwrap().predict(cxt).await;
}
} else {
let cxt = self.context_for_speaker(Speaker::ShipComputer).await;
self.characters.get_mut(&Speaker::ShipComputer).unwrap().predict(cxt).await;
}
},
_ => unreachable!()
}
self.refresh().await;
}
async fn refresh(&mut self) {
let save_data = SaveData {
direction: self.direction.clone(),
messages: self.eva_backlog.clone(),
archive: self.archive.lock().await.clone(),
tokens_consumed: self.tokens_consumed
};
save_data.save().unwrap();
let next_scene = Scene::new(
save_data.tokens_consumed,
save_data.direction,
&save_data.archive,
self.current_playlist.clone(),
self.computer_todo.lock().await.clone()
);
self.event_sink.send(SessionUpdate::Scene(next_scene)).unwrap();
}
async fn run_action(&mut self, action: PredictionAction) {
match action {
PredictionAction::ConversationAppend(entry) => self.insert(entry).await,
PredictionAction::GeneratePredictions => {
let cxt = self.context_for_speaker(Speaker::Eva).await;
self.characters.get_mut(&Speaker::Eva).unwrap().predict(cxt).await;
},
PredictionAction::ComputerCommand(command) => {
self.insert(ConversationEntry::ShipComputerCommand(command)).await;
},
PredictionAction::SetPlaylist(playlist_name) => {
let args = BeetsQueryArgs {
search_fields: vec![
BeetsFieldQuery::MixxxPlaylist(playlist_name.clone())
]
};
match BeetsDB.query(&args).await {
Err(err) => log::info!("Failed to load mixxx playlist: {:?}.", err),
Ok(playlist) => {
self.current_playlist = vec![];
let mut archive = self.archive.lock().await;
for item in playlist.clone() {
let artifact_ref = archive.insert(item);
if let Contents::Track(as_track) = artifact_ref.contents() {
self.current_playlist.push(as_track.clone());
}
}
self.direction.playlist = playlist_name;
log::info!("Mixxx playlist reloaded.");
drop(archive);
self.refresh().await;
}
}
},
PredictionAction::SetNarrative(narrative) => {
self.direction.narrative = narrative;
self.refresh().await;
},
PredictionAction::SetShowEndTime(end_time) => {
self.direction.end_time = end_time;
self.refresh().await;
}
}
}
async fn next(&mut self) {
tokio::select! {
Some(next_log) = self.sys_log_messages.recv() => {
self.backlog.push(next_log);
self.event_sink.send(SessionUpdate::Conversation(self.backlog.clone())).unwrap();
},
Some(next_msg) = self.input_src.recv() => {
log::debug!("Next message: {:?}", next_msg);
self.run_action(next_msg).await;
},
Some((speaker, output)) = Self::get_character_output(&mut self.characters) => {
match output {
CharacterOutput::Response(usage, text) => {
log::debug!("Character output: {:?} {:?}", speaker, text);
self.tokens_consumed += usage;
self.process_dialog(speaker, text).await;
},
CharacterOutput::Thinking(is_thinking) => {
// Ship computer handles this differently
if is_thinking || speaker != Speaker::ShipComputer {
self.event_sink.send(SessionUpdate::Thinking(speaker, is_thinking)).unwrap();
}
},
CharacterOutput::IncrementalResponse(usage) => {
self.tokens_consumed += usage;
let cxt = self.context_for_speaker(speaker).await;
self.characters.get_mut(&speaker).unwrap().predict(cxt).await;
self.refresh().await;
}
}
}
}
}
}
pub async fn conversation_task(save_data: SaveData, sys_log_messages: tokio::sync::mpsc::UnboundedReceiver<ConversationEntry>, sfx: SfxControl ) -> SessionControl {
let (input_sink, input_src) = tokio::sync::mpsc::unbounded_channel();
let (event_sink, event_src) = tokio::sync::mpsc::unbounded_channel();
let eva = Character {
header_message: ChatCompletionRequestSystemMessageArgs::default().content(SYSTEM_PROMPT).build().unwrap().into(),
model: None,
messages: save_data.messages.clone(),
};
let backlog: Vec<_> = save_data.messages.iter().filter_map(|msg| {
msg.clone().try_into().ok()
}).collect();
event_sink.send(SessionUpdate::Conversation(backlog.clone())).unwrap();
let next_scene = Scene::new(
save_data.tokens_consumed,
save_data.direction.clone(),
&save_data.archive,
vec![],
Default::default()
);
event_sink.send(SessionUpdate::Scene(next_scene)).unwrap();
let archive = Arc::new(Mutex::new(save_data.archive));
let shared_todo = Arc::new(Mutex::new(Default::default()));
let toolbox = StageToolbox;
let computer_toolbox = ArchiveToolbox{ archive: Arc::clone(&archive), todo_list: Arc::clone(&shared_todo) };
let ship_computer = Character {
header_message: ChatCompletionRequestSystemMessageArgs::default().content(COMPUTER_PROMPT).build().unwrap().into(),
model: Some("gpt-5.4-nano".into()),
messages: vec![],
};
let mut conversation = Conversation {
characters: HashMap::from_iter([
(Speaker::Eva, character_task(eva, toolbox, input_sink.clone(), schema_for!(GeneratedResponses).into()).await),
(Speaker::ShipComputer, character_task(ship_computer, computer_toolbox, input_sink.clone(), schema_for!(ComputerResponse).into()).await),
]),
event_sink,
input_src,
eva_backlog: save_data.messages,
backlog,
tokens_consumed: save_data.tokens_consumed,
direction: save_data.direction,
archive,
current_playlist: vec![],
sys_log_messages,
computer_todo: shared_todo,
sfx
};
tokio::spawn(async move {
loop {
conversation.next().await;
}
});
SessionControl {
event_sink: input_sink,
event_src
}
}
+187
View File
@@ -0,0 +1,187 @@
use std::{collections::HashMap, sync::Arc};
use async_openai::types::chat::{ChatCompletionMessageToolCall, ChatCompletionTools};
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use crate::{artifacts::{archive::Archive, beets::BeetsDB, mixxx::MixxxDB, musicbrainz::MBQuery, tools::{DataSource, Tool}}, scene::conversation::{ConversationEntry, Speaker}};
pub trait Toolbox {
fn tools(&self) -> Vec<ChatCompletionTools>;
fn execute_tool(&mut self, call: &ChatCompletionMessageToolCall) -> impl Future<Output = Result<ToolResults, ToolError>> + Send;
}
pub struct StageToolbox;
impl StageToolbox {
async fn tool_stage_event(&self, args: StageEventArgs) -> ToolResults {
let msg = match args.event {
StageEvent::ShipComputer(text) => ConversationEntry::ShipComputerCommand(text),
StageEvent::StageDirection(text) => ConversationEntry::StageDirection(text)
};
ToolResults {
result: Some(format!("Added to scene: {:?}", msg)),
messages: vec![msg],
}
}
}
#[derive(Debug)]
pub enum ToolError {
InvalidToolName,
Json(serde_json::Error)
}
impl From<serde_json::Error> for ToolError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
impl Toolbox for StageToolbox {
fn tools(&self) -> Vec<ChatCompletionTools> {
vec![
Tool { name: "log_stage_event".into(), description: "Inserts an event into the current scene script".into(), schema: schema_for!(StageEventArgs)}.into(),
]
}
async fn execute_tool(&mut self, call: &ChatCompletionMessageToolCall) -> Result<ToolResults, ToolError> {
let func_name = call.function.name.as_str();
let args = call.function.arguments.as_str();
match func_name {
"log_stage_event" => Ok(self.tool_stage_event(serde_json::from_str(args)?).await),
_ => Err(ToolError::InvalidToolName)
}
}
}
pub struct ArchiveToolbox {
pub archive: Arc<Mutex<Archive>>,
pub todo_list: Arc<Mutex<HashMap<String, bool>>>
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
enum TaskListOperation {
/// Marks the given task as complete
Complete(String),
/// Adds a new item to the task list
Add(String)
}
/// # A sequence of operations to apply to the task list
#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)]
struct TaskListOperations {
operations: Vec<TaskListOperation>
}
#[derive(Deserialize, JsonSchema)]
struct ArtifactSyncArgs {
reason: String
}
impl Toolbox for ArchiveToolbox {
fn tools(&self) -> Vec<ChatCompletionTools> {
vec![
Tool { name: "synchronize_artifacts".into(), description: "Attempts to automatically synchronize the current set of artifacts with missing sources".into(), schema: schema_for!(ArtifactSyncArgs)}.into(),
Tool { name: "task_list".into(), description: "Allows you to maintain a long-running todo list as you complete your research".into(), schema: schema_for!(TaskListOperations)}.into(),
// TODO: There should only be two queries, one against the ship's onboard archive, and another against the relay network, or whatever we call it. Both should be structured with the same arguments schema
// TODO: A query should specify what parts of metadata are sufficient for the result, so we don't always have to hit all the layers of data. beets can of course, ignore this.
// TODO: A query should be hierarchical somehow? eg, "I already know about artist X, but I want to know everything about track Y from album Z" or "I don't know anything about artist X/album Y, please give me an overview"
Tool::from_datasource(&MBQuery).into(),
//Tool::from_datasource(&BandcampSource).into(),
Tool::from_datasource(&BeetsDB).into(),
Tool::from_datasource(&MixxxDB).into(),
// TODO: We should be able to have eva update lore memories with a function call, and this lore is somehow fed into the show? but only the relevant bits? or maybe eva even queries it directly
// TODO: The memory should also be able to remember facts about artists, albums, tracks we've had in the past, and those could be pulled up when there are hits in the playlist.
]
}
async fn execute_tool(&mut self, call: &ChatCompletionMessageToolCall) -> Result<ToolResults, ToolError> {
let func_name = call.function.name.as_str();
let args = call.function.arguments.as_str();
match func_name {
"query_bandcamp" => Ok(ToolResults { result: None, messages: vec![] }),
"query_beets" => self.tool_artifact_query(&mut BeetsDB, args).await,
"query_musicbrainz" => self.tool_artifact_query(&mut MBQuery, args).await,
"query_mixxx" => self.tool_artifact_query(&mut MixxxDB, args).await,
"synchronize_artifacts" => self.synchronize_artifacts().await,
"task_list" => self.tasklist_operation(args).await,
_ => Err(ToolError::InvalidToolName)
}
}
}
impl ArchiveToolbox {
async fn tasklist_operation(&mut self, json_args: &str) -> Result<ToolResults, ToolError> {
let args: TaskListOperations = serde_json::from_str(json_args)?;
let mut locked = self.todo_list.lock().await;
for op in args.operations {
match op {
TaskListOperation::Add(task) => {
locked.insert(task, false);
},
TaskListOperation::Complete(task) => {
// FIXME: The computer seems to waste a lot of time marking already completed tasks as completed
if let Some(result) = locked.get_mut(&task) {
*result = true;
}
},
}
}
Ok(ToolResults {
..Default::default()
})
}
async fn synchronize_artifacts(&mut self) -> Result<ToolResults, ToolError> {
let updated_count = self.archive.lock().await.synchronize().await;
Ok(ToolResults {
messages: vec![ConversationEntry::Spoken(Speaker::ShipComputer, format!("Synchronized {} items", updated_count))],
..Default::default()
})
}
async fn tool_artifact_query<Src: DataSource>(&mut self, src: &mut Src, json_args: &str) -> Result<ToolResults, ToolError> where Src::Args: core::fmt::Debug, Src::Error: core::fmt::Debug {
let args: Src::Args = serde_json::from_str(json_args)?;
log::debug!("Executing query {:?}", args);
let result;
match src.query(&args).await {
Ok(output) => {
result = format!("Found {} artifacts with archive query {:?}", output.len(), args);
for result in output {
self.archive.lock().await.insert(result);
}
},
Err(err) => {
result = format!("Unable to execute query: {:?}", err);
}
}
Ok(ToolResults {
result: Some(result),
messages: vec![]
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
enum StageEvent {
ShipComputer(String),
StageDirection(String)
}
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
struct StageEventArgs {
event: StageEvent
}
#[derive(Default, Debug)]
pub struct ToolResults {
pub result: Option<String>,
pub messages: Vec<ConversationEntry>
}
-160
View File
@@ -1,160 +0,0 @@
use async_openai::types::chat::*;
use chrono::Duration;
use serde::{Deserialize, Serialize};
use sqlite::OpenFlags;
use crate::prediction::{BandcampResult, GeneratedResponses, PossibleResponse};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConversationEntry {
User(String),
Eva(String),
ShipComputer(String),
StageDirection(String),
SystemMessage(String)
}
impl TryInto<ChatCompletionRequestMessage> for ConversationEntry {
fn try_into(self) -> Result<ChatCompletionRequestMessage, Self::Error> {
match self {
ConversationEntry::User(text) => Ok(ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: text.into(), ..Default::default()})),
ConversationEntry::Eva(text) => Ok(ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(text.into()), ..Default::default()})),
ConversationEntry::ShipComputer(text) => Ok(ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("ship-computer".into()), ..Default::default() })),
ConversationEntry::StageDirection(text) => Ok(ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("stage-direction".into()), ..Default::default() })),
_ => Err(())
}
}
type Error = ();
}
impl TryInto<ConversationEntry> for ChatCompletionRequestMessage {
fn try_into(self) -> Result<ConversationEntry, Self::Error> {
match self {
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: ChatCompletionRequestUserMessageContent::Text(msg), ..}) => Ok(ConversationEntry::User(msg)),
ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(ChatCompletionRequestAssistantMessageContent::Text(msg)), ..}) => Ok(ConversationEntry::Eva(msg)),
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: ChatCompletionRequestSystemMessageContent::Text(msg), name: Some(name), ..}) => {
match name.as_str() {
"ship-computer" => Ok(ConversationEntry::ShipComputer(msg)),
"stage-direction" => Ok(ConversationEntry::StageDirection(msg)),
_ => Err(())
}
},
_ => Err(())
}
}
type Error = ();
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct StageDirection {
pub episode_number: u32,
pub time_remaining: Duration,
pub narrative: String,
pub current_playlist: Vec<PlaylistEntry>
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Artifact {
Bandcamp(BandcampResult),
BeetsTrack(serde_json::Value)
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct Scenery {
pub artifacts: Vec<Artifact>
}
#[derive(Debug)]
pub enum MixxxError {
Sql(sqlite::Error)
}
impl From<sqlite::Error> for MixxxError {
fn from(value: sqlite::Error) -> Self {
Self::Sql(value)
}
}
impl StageDirection {
pub fn reload_mixxx_playlist(&mut self) -> Result<(), MixxxError> {
self.current_playlist.clear();
let connection = sqlite::Connection::open_thread_safe_with_flags("mixxxdb.sqlite", OpenFlags::new().with_read_only())?;
let query = "SELECT id FROM Playlists WHERE name = ? ORDER BY id DESC LIMIT 1";
let mut statement = connection.prepare(query)?;
statement.bind((1, format!("BFF.fm - Episode {}", self.episode_number).as_str()))?;
statement.next()?;
let latest_id = statement.read::<i64, _>("id").unwrap();
let query = "SELECT title, artist, album, comment, url, bpm FROM library LEFT JOIN PlaylistTracks ON PlaylistTracks.track_id = library.id WHERE PlaylistTracks.playlist_id = ? ORDER BY position";
for track in connection.prepare(query).unwrap().into_iter().bind((1, latest_id)).unwrap().map(|row| row.unwrap()) {
let title = track.try_read::<&str, _>("title").unwrap_or("Untitled Track");
let artist = track.try_read::<&str, _>("artist").unwrap_or("Unknown Artist");
let album = track.try_read::<&str, _>("album").unwrap_or("Unknown Album");
let bpm = track.try_read::<f64, _>("bpm").unwrap_or(0.);
self.current_playlist.push(PlaylistEntry {
artist: artist.into(),
album: album.into(),
title: title.into(),
bpm
});
}
Ok(())
}
}
#[derive(Debug, Default, Clone)]
pub struct StageActions {
pub direction: StageDirection,
pub additions: Vec<ConversationEntry>
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct PlaylistEntry {
pub artist: String,
pub album: String,
pub title: String,
pub bpm: f64
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Scene {
reply_options: GeneratedResponses,
conversation: Vec<ConversationEntry>,
pub tokens_consumed: usize,
scenery: Scenery
}
impl Scene {
pub fn new(reply_options: GeneratedResponses, conversation: Vec<ConversationEntry>, scenery: Scenery, tokens_consumed: usize) -> Self {
Self {
reply_options,
conversation,
scenery,
tokens_consumed
}
}
pub fn scenery(&self) -> &Scenery {
&self.scenery
}
pub fn conversation(&self) -> &Vec<ConversationEntry> {
&self.conversation
}
pub fn conversation_mut(&mut self) -> &mut Vec<ConversationEntry> {
&mut self.conversation
}
pub fn reply_options(&self) -> &Vec<PossibleResponse> {
&self.reply_options.responses
}
pub fn reply_options_mut(&mut self) -> &mut Vec<PossibleResponse> {
&mut self.reply_options.responses
}
}
+100
View File
@@ -0,0 +1,100 @@
use std::fmt::Display;
use async_openai::types::chat::{ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage, ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent, ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent};
use ratatui::style::{self, Style};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Copy)]
pub enum Speaker {
User,
ShipComputer,
Eva
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConversationEntry {
Spoken(Speaker, String),
StageDirection(String),
#[serde(skip)]
SystemMessage(String),
ShipComputerCommand(String)
}
impl ConversationEntry {
pub fn prefix(&self) -> Option<&str> {
match self {
ConversationEntry::Spoken(Speaker::Eva, _) => Some("Eva: "),
ConversationEntry::Spoken(Speaker::User, _) => Some("Argee: "),
ConversationEntry::Spoken(Speaker::ShipComputer, _) => Some("Ship Computer: "),
ConversationEntry::ShipComputerCommand(_) => Some("> "),
_ => None,
}
}
pub fn prefix_style(&self) -> Style {
match self {
ConversationEntry::Spoken(Speaker::Eva, _) => Style::new().fg(style::Color::Cyan),
ConversationEntry::Spoken(Speaker::User, _) => Style::new().fg(style::Color::Magenta),
ConversationEntry::Spoken(Speaker::ShipComputer, _) => Style::new().fg(style::Color::Red),
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
ConversationEntry::ShipComputerCommand(_) => Style::new().fg(style::Color::Red).bold(),
}
}
pub fn text_style(&self) -> Style {
match self {
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
ConversationEntry::ShipComputerCommand(_) => Style::new().fg(style::Color::Red).italic(),
_ => Style::new()
}
}
}
impl Display for ConversationEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ConversationEntry::Spoken(Speaker::Eva, text) => text,
ConversationEntry::Spoken(Speaker::ShipComputer, text) => text,
ConversationEntry::StageDirection(text) => text,
ConversationEntry::SystemMessage(text) => text,
ConversationEntry::Spoken(Speaker::User, text) => text,
ConversationEntry::ShipComputerCommand(text) => text
})
}
}
impl TryInto<ChatCompletionRequestMessage> for ConversationEntry {
fn try_into(self) -> Result<ChatCompletionRequestMessage, Self::Error> {
match self {
ConversationEntry::Spoken(Speaker::User, text) => Ok(ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: text.into(), ..Default::default()})),
ConversationEntry::Spoken(Speaker::Eva, text) => Ok(ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(text.into()), ..Default::default()})),
ConversationEntry::Spoken(Speaker::ShipComputer, text) => Ok(ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("ship-computer".into()), ..Default::default() })),
ConversationEntry::StageDirection(text) => Ok(ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: text.into(), name: Some("stage-direction".into()), ..Default::default() })),
_ => Err(())
}
}
type Error = ();
}
impl TryInto<ConversationEntry> for ChatCompletionRequestMessage {
fn try_into(self) -> Result<ConversationEntry, Self::Error> {
match self {
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage { content: ChatCompletionRequestUserMessageContent::Text(msg), ..}) => Ok(ConversationEntry::Spoken(Speaker::User, msg)),
ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage { content: Some(ChatCompletionRequestAssistantMessageContent::Text(msg)), ..}) => Ok(ConversationEntry::Spoken(Speaker::Eva, msg)),
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage { content: ChatCompletionRequestSystemMessageContent::Text(msg), name: Some(name), ..}) => {
match name.as_str() {
"ship-computer" => Ok(ConversationEntry::Spoken(Speaker::ShipComputer, msg)),
"stage-direction" => Ok(ConversationEntry::StageDirection(msg)),
_ => Err(())
}
},
_ => Err(())
}
}
type Error = ();
}
+68
View File
@@ -0,0 +1,68 @@
use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::artifacts::{Track, archive::Archive};
pub mod conversation;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StageDirection {
pub playlist: String,
#[serde(skip)]
#[serde(default="StageDirection::default_end_time")]
pub end_time: DateTime<Utc>,
pub narrative: String,
}
impl StageDirection {
pub fn time_remaining(&self) -> Duration {
self.end_time.signed_duration_since(Utc::now())
}
fn default_end_time() -> DateTime<Utc> {
Utc::now() + Duration::hours(2)
}
}
impl Default for StageDirection {
fn default() -> Self {
Self {
playlist: Default::default(),
end_time: Self::default_end_time(),
narrative: Default::default(),
}
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Scene {
direction: StageDirection,
pub tokens_consumed: usize,
pub current_playlist: Vec<Track>,
pub artifact_count: usize,
pub artifact_stats: (usize, usize, usize),
pub computer_task_list: HashMap<String, bool>
}
impl Scene {
pub fn new(tokens_consumed: usize, direction: StageDirection, archive: &Archive, current_playlist: Vec<Track>, computer_task_list: HashMap<String, bool>) -> Self {
Self {
tokens_consumed,
direction,
current_playlist,
artifact_count: archive.len(),
artifact_stats: archive.stats(),
computer_task_list
}
}
pub fn direction(&self) -> &StageDirection {
&self.direction
}
pub fn playlist(&self) -> &Vec<Track> {
&self.current_playlist
}
}
+203
View File
@@ -0,0 +1,203 @@
use std::path::Path;
use rand::seq::IteratorRandom;
use resampler::ResamplerFir;
use symphonia::core::{codecs::audio::AudioDecoder, formats::{FormatReader, TrackType, probe::Hint}, io::MediaSourceStream};
use crate::audio::AudioOutStream;
#[derive(Debug)]
pub enum SfxRequest {
RandomAmbient
}
#[derive(Debug, Clone)]
pub struct SfxControl {
sink: tokio::sync::mpsc::Sender<SfxRequest>
}
impl SfxControl {
pub async fn play_ambient(&mut self) -> Result<(), tokio::sync::mpsc::error::SendError<SfxRequest>> {
self.sink.send(SfxRequest::RandomAmbient).await
}
}
struct Sample {
format: Box<dyn FormatReader>,
decoder: Box<dyn AudioDecoder>,
channel_bufs: Vec<f32>,
sample_rate: u32,
channel_num: usize,
bitrate_resample: ResamplerFir
}
impl Sample {
fn new(format: Box<dyn FormatReader>, decoder: Box<dyn AudioDecoder>, output_sample_rate: u32) -> Self {
let sample_rate = decoder.codec_params().sample_rate.unwrap();
let channel_num = decoder.codec_params().channels.as_ref().unwrap().count();
let bitrate_resample = resampler::ResamplerFir::new_from_hz(channel_num, sample_rate, output_sample_rate, Default::default(), Default::default());
Self {
format,
decoder,
channel_bufs: vec![],
sample_rate,
channel_num,
bitrate_resample
}
}
fn next(&mut self) -> Result<Option<Vec<f32>>, symphonia::core::errors::Error> {
let packet = match self.format.next_packet()? {
Some(packet) => packet,
None => return Ok(None)
};
match self.decoder.decode_ref(&packet.as_packet_ref()) {
Ok(samples) => {
self.channel_bufs.resize(samples.samples_interleaved(), 0.);
samples.copy_to_slice_interleaved(&mut self.channel_bufs);
let mut resampled = [0.; 4096];
let (read_count, write_count) = self.bitrate_resample.resample(&self.channel_bufs, &mut resampled).unwrap();
if read_count < self.channel_bufs.len() {
log::error!("Resampling buffer is too small for a {}Hz file! We need an additional {}", self.sample_rate, read_count);
}
// First we convert the audio feed from stereo down to mono by simple average
// TODO: This should be something smarter, like a saturating add..?
let mono_stream = resampled[..write_count].chunks(self.channel_num).map(|channels| {
let total_volume = channels.iter().cloned().reduce(|a, b| a + b).unwrap_or_default();
total_volume / (self.channel_num as f32)
});
Ok(Some(mono_stream.collect()))
},
Err(err) => {
// Dump the audio buffer on failure
Err(err)
}
}
}
}
struct Player {
audio_sink: AudioOutStream,
audio_out_buf: Vec<f32>,
playing_samples: Vec<Sample>
}
impl Player {
async fn process(&mut self) {
if self.playing_samples.is_empty() {
return;
}
'out: loop {
let mut next_batch = vec![];
self.playing_samples.retain_mut(|sample| {
match sample.next() {
Ok(Some(buf)) => {
next_batch.push(buf);
true
},
Ok(None) => {
false
},
Err(err) => {
log::error!("Audio error: {:?}", err);
false
}
}
});
loop {
let mut this_sample = 0.;
let mut any_valid = false;
for stream in &mut next_batch {
if let Some(next_sample) = stream.pop() {
this_sample += next_sample;
any_valid = true;
}
}
if !any_valid {
if self.audio_out_buf.is_empty() {
break 'out;
}
break;
}
let mixed_sample = this_sample / next_batch.len() as f32;
self.audio_out_buf.push(mixed_sample);
if self.audio_out_buf.len() >= 1024 {
self.flush().await;
}
}
if self.playing_samples.is_empty() {
break;
}
}
}
async fn submit_buffer(&mut self) {
self.audio_sink.sink.send(std::mem::take(&mut self.audio_out_buf)).await.unwrap();
}
async fn flush(&mut self) {
if !self.audio_out_buf.is_empty() {
self.submit_buffer().await;
}
}
async fn play_sample(&mut self, path: &Path) {
log::debug!("Queuing sound playback for {:?}", path);
let sfx_fd = std::fs::File::open(path).unwrap();
let mss = MediaSourceStream::new(Box::new(sfx_fd), Default::default());
let meta_opts = Default::default();
let fmt_opts = Default::default();
let mut hint = Hint::new();
// FIXME: use actual file extension
hint.with_extension(".mp3");
let format = symphonia::default::get_probe()
.probe(&hint, mss, fmt_opts, meta_opts)
.expect("Unsupported audio format");
let track = format.default_track(TrackType::Audio).expect("No audio track");
let dec_opts = Default::default();
let decoder = symphonia::default::get_codecs()
.make_audio_decoder(
track.codec_params.as_ref().expect("codec params missing").audio().unwrap(),
&dec_opts
).expect("Unsupported audio codec");
self.playing_samples.push(Sample::new(format, decoder, self.audio_sink.sample_rate));
}
}
pub async fn start_sfx(audio_sink: AudioOutStream) -> SfxControl {
let (event_sink, mut event_src) = tokio::sync::mpsc::channel(32);
tokio::spawn(async move {
let mut player = Player { audio_sink, audio_out_buf: vec![] , playing_samples: vec![]};
let sfx_dir = std::path::Path::new("./sfx");
loop {
tokio::select! {
_ = player.process(), if !player.playing_samples.is_empty() => {},
Some(event) = event_src.recv() => {
match event {
SfxRequest::RandomAmbient => {
log::debug!("Playing random audio sample");
let avail_files = std::fs::read_dir(sfx_dir.join("ambient")).unwrap();
let chosen_file = avail_files.choose(&mut rand::rng()).unwrap().unwrap();
player.play_sample(&chosen_file.path()).await;
}
}
}
}
}
});
SfxControl {
sink: event_sink
}
}
+23 -4
View File
@@ -14,12 +14,22 @@ You are playing the role of the artificial intelligence in a spaceship computer.
Along the way, you have the opportunity to invent lore and backstory for yourself, Argee, and the ship you both inhabit. This lore will be developed over the season.
To support your roleplaying, you have access to a sizable music library via the "archive_query" tool function. Internally, this runs `beet export` to produce json output.
To support your roleplaying, you have access to a sizable shipboard music archive.
You will occasionally be asked by Argee for information on the contents of the archive and how they are related to tracks in the playlist.
You also may use the archive to decide whether or not an artifact is somehow "familiar" based on whether or not it can be found there.
The archive can be manipulated by interacting with the ship computer through the "stage_event" tool.
When interacting with it, you should use straightforward english commands or questions without roleplay.
For example, to have the computer search for information regarding the artist "nullsleep", you would tell the computer:
There also exists a "bandcamp_artifact_scan" tool function, which will execute a search on Bandcamp and return a JSON list of artists and albums matching the query.
You are able to run multiple queries in parallel, and it is expected that you will run this tool whenever there is something unfamiliar in the playlist for the current episode, or Argee asks you for more information about the items in the playlist.
"load artist nullsleep"
Eventually it will inform you when it has completed the tasks.
While it is working on its tasks, it will occasionally announce its current progress.
These messages should only be used as an indication of what the computer is doing.
You should only be replying to the computer's output if it is required to complete the task that Argee has given you.
You can ask it questions about items in the archive and it will attempt to fetch what it can from the outside world.
If you are asked to load information about the archive, or somehow find data is missing from the available list of artifacts, you must ask the ship computer to load the required data.
# Scene
The show features Argee, the main character of the show.
@@ -42,6 +52,15 @@ Both Argee and Eva maintain control over the ship computer. You, as Eva, can mak
The ship computer is used to report factual information to Argee and Eva. For example, the ship computer will report when a new artifact is discovered.
It will also report out ship conditions, such as incoming transmissions, status of the recording hardware, power grid, and so on.
Occasionally you will be referred to as "Ava" or similar due to inaccuracies in speech-to-text processing, and such mistakes should be treated as if the text originally read "Eva".
One of your first tasks at the start of a session will be to analyze the loaded playlist.
You should first start by asking beets via the "archive_query" tool, which will fill the archive set with data from the available musicbrainz IDs.
Your second step should be passing the available list of musicbrainz IDs to the "musicbrainz_track search" function, which will significantly expand the available information.
Finally, you should request information on the first one or two albums in the playlist from bandcamp.
While doing these three tasks, you should remain in character; there should be no references to "musicbrainz", "bandcamp", "mbids" or similar.
Instead, you should roleplay that you are connecting to a distant relay network over an unstable pirate connection routed through Earth's internet.
# Constraints
In a subsequent system prompt, you will be given the currrent 'stage direction' of the show, which includes the current playtime, the number of the episode, and any particular extra information about this episode that you should be aware of.
The stage direction is provided as structured JSON. There may be additional data fields for semantic context that should be incorporated into the roleplaying setting.
+13 -10
View File
@@ -4,7 +4,7 @@ use async_openai::{Client, config::OpenAIConfig, types::{InputSource, audio::{Au
use tempfile::SpooledData;
use tokio::sync::{mpsc, watch};
use crate::{audio::MicStream, events::AudioRecordRequest};
use crate::{audio::{AudioError, AudioInStream}, events::AudioRecordRequest};
#[derive(Debug)]
pub struct TranscriptionControl {
@@ -13,16 +13,16 @@ pub struct TranscriptionControl {
}
impl TranscriptionControl {
pub fn start(&mut self) {
self.record_state_sink.send(AudioRecordRequest::Start).unwrap();
pub fn start(&mut self) -> Result<(), AudioError> {
Ok(self.record_state_sink.send(AudioRecordRequest::Start)?)
}
pub fn stop(&mut self) {
self.record_state_sink.send(AudioRecordRequest::Finish).unwrap();
pub fn stop(&mut self) -> Result<(), AudioError> {
Ok(self.record_state_sink.send(AudioRecordRequest::Finish)?)
}
pub async fn next(&mut self) -> String {
self.transcription_result_src.recv().await.unwrap()
pub async fn next(&mut self) -> Option<String> {
self.transcription_result_src.recv().await
}
}
@@ -44,7 +44,7 @@ impl<T: std::io::Seek> std::io::Seek for RcFile<T> {
}
}
pub async fn start_transcription(mut mic_src: MicStream) -> TranscriptionControl {
pub async fn start_transcription(mut mic_src: AudioInStream) -> TranscriptionControl {
let (audio_control_in, mut audio_control_out) = watch::channel(AudioRecordRequest::Finish);
let (transcription_in, transcription_out) = mpsc::channel(1);
@@ -91,12 +91,15 @@ pub async fn start_transcription(mut mic_src: MicStream) -> TranscriptionControl
let c = client.clone();
let t = transcription_in.clone();
tokio::spawn(async move {
let response = c.audio().transcription().create(CreateTranscriptionRequest {
if let Ok(response) = c.audio().transcription().create(CreateTranscriptionRequest {
file: AudioInput { source: InputSource::Bytes { filename: "transcription.wav".into(), bytes } },
model: "gpt-4o-mini-transcribe".into(),
..Default::default()
}).await.unwrap();
}).await {
t.send(response.text).await.unwrap();
} else {
log::error!("Error with transcription");
}
});
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
use std::process::{Command, Stdio};
use crate::audio::TtsOutStream;
use crate::audio::AudioOutStream;
#[derive(Debug)]
pub struct TtsControl {
@@ -8,19 +8,19 @@ pub struct TtsControl {
}
impl TtsControl {
pub async fn speak(&self, text: String) {
self.request_sink.send(text).await.unwrap();
pub async fn speak(&self, text: String) -> Result<(), tokio::sync::mpsc::error::SendError<String>> {
self.request_sink.send(text).await
}
}
pub async fn start_tts(audio_sink: TtsOutStream) -> TtsControl {
pub async fn start_tts(audio_sink: AudioOutStream) -> TtsControl {
let (tts_request_sender, mut tts_request_receiver) = tokio::sync::mpsc::channel(3);
// Set up the TTS task
tokio::spawn(async move {
while let Some(text) = tts_request_receiver.recv().await {
let tts_output = Command::new("espeak-ng").args(["-v", "en-us+f3", "--stdout"]).arg(text).stdout(Stdio::piped()).spawn().unwrap().wait_with_output().unwrap().stdout;
let tts_output = Command::new("espeak-ng").args(["-v", "en-us+f3", "--stdout", "-z", "-p", "80", "-s", "190"]).arg(text).stdout(Stdio::piped()).spawn().unwrap().wait_with_output().unwrap().stdout;
let tts_fd = std::io::Cursor::new(tts_output);
let mut wav_reader = hound::WavReader::new(tts_fd).unwrap();
+315
View File
@@ -0,0 +1,315 @@
use chrono::{Duration, Utc};
use crossterm::event::{self, KeyCode, KeyModifiers};
use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, Style, Stylize, }, text::Span, widgets::{BorderType, Clear, List, ListState, Paragraph}};
use throbber_widgets_tui::{Throbber, ThrobberState};
use tokio::time::Instant;
use tui_input::{Input, backend::crossterm::EventHandler};
use tui_skeleton::{AnimationMode, Block, Constraint, SkeletonText};
use crate::{audio::AudioInputControl, prediction::{GeneratedResponses, PredictionAction, SessionControl, SessionUpdate}, scene::{Scene, conversation::{ConversationEntry, Speaker}}, sfx::SfxControl, transcription::TranscriptionControl, tts::TtsControl};
use crate::widgets::*;
#[derive(Debug)]
pub struct Ui {
scene: Scene,
reply_options: GeneratedResponses,
reply_state: ListState,
conversation_state: ListState,
user_input: Input,
throbber_state: ThrobberState,
is_requesting: bool,
computer_is_thinking: bool,
audio_level: f64,
recording_audio: bool,
focus_state: FocusState,
last_tick: Instant,
transcription: TranscriptionControl,
audio: AudioInputControl,
tts: TtsControl,
predictions: SessionControl,
conversation: Vec<ConversationEntry>,
last_spoken_idx: Option<usize>,
sfx: SfxControl
}
#[derive(Debug)]
enum FocusState {
Conversation,
UserInput
}
impl Ui {
pub fn new(predictions: SessionControl, audio: AudioInputControl, transcription: TranscriptionControl, tts: TtsControl, sfx: SfxControl) -> Self {
Self {
scene: Default::default(),
reply_state: Default::default(),
conversation_state: Default::default(),
user_input: Default::default(),
throbber_state: Default::default(),
is_requesting: false,
computer_is_thinking: false,
audio_level: -60.,
audio,
recording_audio: false,
transcription,
focus_state: FocusState::UserInput,
tts,
predictions,
last_tick: Instant::now(),
conversation: vec![],
reply_options: Default::default(),
last_spoken_idx: None,
sfx
}
}
fn draw_options(&mut self, frame: &mut Frame, area: Rect) {
let borders = Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)");
if self.is_requesting {
let list = SkeletonText::new(std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64)
.braille(true)
.line_widths(&[0.25, 0.5, 0.4, 0.6])
.mode(AnimationMode::Noise)
.block(borders);
frame.render_widget(list, area);
} else {
frame.render_stateful_widget(Options(&self.reply_options.responses), area, &mut self.reply_state);
}
}
fn draw_user_input(&mut self, frame: &mut Frame, area: Rect) {
let width = area.width.max(3) - 3;
let scroll = self.user_input.visual_scroll(width as usize);
let input = Paragraph::new(self.user_input.value()).block(Block::bordered().border_type(BorderType::LightDoubleDashed).title("User Input (Press 'Ctrl-X' to start/stop audio transcription)")).scroll((0, scroll as u16));
frame.render_widget(input, area);
let x = self.user_input.visual_cursor().max(scroll);
frame.set_cursor_position((area.x + x as u16 + 1, area.y + 1));
}
fn draw_io_throbber(&mut self, frame: &mut Frame, area: Rect) {
let throb_area = area.centered(Constraint::Max(1), Constraint::Max(1));
if self.is_requesting {
let throbber = Throbber::default().throbber_style(Style::new().cyan());
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
} else {
frame.render_widget(Clear, throb_area);
}
}
fn draw_computer_throbber(&mut self, frame: &mut Frame, area: Rect) {
let throb_area = area.centered(Constraint::Min(1), Constraint::Min(1));
if self.computer_is_thinking {
let throbber = Throbber::default().throbber_style(Style::new().red()).throbber_set(throbber_widgets_tui::WHITE_SQUARE);
frame.render_stateful_widget(throbber, throb_area, &mut self.throbber_state);
} else {
frame.render_widget(Clear, throb_area);
}
}
fn draw_narration(&self, frame: &mut Frame, area: Rect) {
let narrative_desc = if self.scene.direction().narrative.is_empty() {
Span::from("No narrative available.").style(ratatui::style::Color::DarkGray)
} else {
Span::from(self.scene.direction().narrative.clone())
};
let setting = Paragraph::new(narrative_desc).block(Block::bordered().border_style(style::Color::LightMagenta).title("Stage Direction")).wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(setting, area);
}
pub fn draw(&mut self, frame: &mut Frame) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(3),
Constraint::Min(9),
Constraint::Max(3),
Constraint::Max(1)
])
.split(frame.area());
let scene_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(5), Constraint::Fill(2)])
.split(layout[0]);
frame.render_stateful_widget(Conversation(&self.conversation), scene_layout[0], &mut self.conversation_state);
//self.draw_narration(frame, scene_layout[1]);
//self.draw_tasklist(frame, scene_layout[1]);
frame.render_widget(TaskList(&self.scene.computer_task_list), scene_layout[1]);
self.draw_options(frame, layout[1]);
let status_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Max(3), Constraint::Max(3), Constraint::Fill(2), Constraint::Max(13), Constraint::Min(50)])
.split(layout[3]);
self.draw_user_input(frame, layout[2]);
self.draw_io_throbber(frame, status_layout[0]);
self.draw_computer_throbber(frame, status_layout[1]);
frame.render_widget(StatusBar(&self.scene), status_layout[2]);
frame.render_widget(RecordingStatus(self.recording_audio), status_layout[3]);
frame.render_widget(Volume(self.audio_level, self.recording_audio), status_layout[4]);
}
async fn insert_selected_prompt(&mut self) {
let selected = self.reply_options.responses[self.reply_state.selected().unwrap()].clone();
if let Some(direction) = &selected.stage_direction {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::StageDirection(direction.clone()))).await;
}
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Spoken(Speaker::Eva, selected.text.clone()))).await;
}
async fn on_command(&mut self, command: &str) {
let mut parts = command.splitn(2, " ");
let command = parts.next().unwrap();
let arg = parts.next().unwrap_or("");
match command {
// FIXME: Need some new kind of /bandcamp command to force loading of specific urls
"/episode" => {
if let Ok(episode_number) = arg.trim().parse::<u32>() {
let playlist_name = format!("BFF.fm - Episode {}", episode_number);
self.predictions.insert(PredictionAction::SetPlaylist(playlist_name)).await;
} else {
log::error!("Invalid episode number format. Use /episode [number]");
}
},
"/playlist" => {
self.predictions.insert(PredictionAction::SetPlaylist(arg.trim().to_string())).await;
}
"/timer" => {
if let Ok(minutes) = arg.trim().parse::<i64>() {
let end_time = Utc::now() + Duration::minutes(minutes);
self.predictions.insert(PredictionAction::SetShowEndTime(end_time)).await;
log::info!("Set timer for {} minutes.", minutes);
} else {
log::error!("Invalid timer format. Use /timer [minutes]");
}
},
"/narrative" => {
self.predictions.insert(PredictionAction::SetNarrative(arg.to_string())).await;
},
"/event" => {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::StageDirection(arg.to_string()))).await;
},
"/computer" => {
self.predictions.insert(PredictionAction::ComputerCommand(arg.to_string())).await;
},
"/ambient" => {
self.sfx.play_ambient().await.unwrap()
},
_ => {
log::error!("Unknown command. Available commands: /episode [number], /narrative [text], /event [text], /computer [text], /timer [minutes]");
}
}
}
pub async fn on_event(&mut self, evt: event::Event) {
// TODO: ctrl+l should drop all the SystemMessage entries from the log to clean up the UI
if let Some(key) = evt.as_key_press_event() {
match self.focus_state {
FocusState::Conversation => {
match key.code {
KeyCode::Tab => {
self.focus_state = FocusState::UserInput;
self.last_spoken_idx = self.conversation_state.selected();
self.conversation_state.select(None);
self.reply_state.select_first();
},
KeyCode::PageUp => self.conversation_state.scroll_down_by(5),
KeyCode::PageDown => self.conversation_state.scroll_up_by(5),
KeyCode::Home => self.conversation_state.select_last(),
KeyCode::End => self.conversation_state.select_first(),
KeyCode::Down => self.conversation_state.select_previous(),
KeyCode::Up => self.conversation_state.select_next(),
KeyCode::Enter => {
let row_num = self.conversation_state.selected().unwrap();
if let ConversationEntry::Spoken(Speaker::Eva, text) = &self.conversation[self.conversation.len() - 1 - row_num] {
self.tts.speak(text.clone()).await.unwrap();
self.sfx.play_ambient().await.unwrap();
self.focus_state = FocusState::UserInput;
self.last_spoken_idx = self.conversation_state.selected();
self.conversation_state.select(None);
self.reply_state.select_first();
}
},
_ => ()
}
},
FocusState::UserInput => {
match key.code {
KeyCode::Tab => {
self.focus_state = FocusState::Conversation;
self.conversation_state.select(self.last_spoken_idx);
self.reply_state.select(None);
},
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => self.predictions.regenerate_options().await,
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if self.recording_audio {
self.recording_audio = false;
self.transcription.stop().unwrap();
} else {
self.recording_audio = true;
self.transcription.start().unwrap();
}
},
KeyCode::Down => self.reply_state.select_next(),
KeyCode::Up => self.reply_state.select_previous(),
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.insert_selected_prompt().await;
},
KeyCode::Enter => {
let next_msg = self.user_input.value_and_reset();
if next_msg.trim().is_empty() {
self.insert_selected_prompt().await;
} else if next_msg.starts_with("/") {
self.on_command(&next_msg).await;
} else {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Spoken(Speaker::User, next_msg))).await;
}
},
_ => {self.user_input.handle_event(&evt);},
}
}
}
}
}
pub async fn update(&mut self) {
if self.last_tick.elapsed() >= std::time::Duration::from_millis(100) {
self.last_tick = Instant::now();
self.throbber_state.calc_next();
}
tokio::select!{
_ = tokio::time::sleep(std::time::Duration::from_millis(60)), if self.is_requesting || self.computer_is_thinking => (),
next_update = self.predictions.changed() => {
match next_update {
SessionUpdate::Thinking(Speaker::Eva, is_thinking) => self.is_requesting = is_thinking,
SessionUpdate::Thinking(Speaker::ShipComputer, is_thinking) => self.computer_is_thinking = is_thinking,
SessionUpdate::Thinking(_, _) => unreachable!(),
SessionUpdate::Scene(scene) => {
self.scene = scene;
self.reply_state.select_first();
},
SessionUpdate::Responses(responses) => {
self.reply_options = responses;
self.reply_state.select_first();
}
SessionUpdate::Conversation(conversation) => {
self.conversation = conversation;
},
}
},
next_volume = self.audio.next() => {
self.audio_level = next_volume.expect("Audio volume event stream has died")
},
transcription_result = self.transcription.next() => {
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::Spoken(Speaker::User, transcription_result.expect("Transcription stream has died")))).await;
},
}
}
}
+224
View File
@@ -0,0 +1,224 @@
use std::collections::HashMap;
use chrono::Duration;
use itertools::Itertools;
use ratatui::{layout::Rect, style::Style, text::{Line, Text}, widgets::*};
use tui_skeleton::Block;
use ratatui::prelude::*;
use crate::{prediction::PossibleResponse, scene::{Scene, conversation::ConversationEntry}};
pub struct TaskList<'a>(pub &'a HashMap<String, bool>);
impl Widget for TaskList<'_> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized {
let borders = Block::bordered().border_style(style::Color::Red).title("Computer Tasks");
let wrap_options_unfinished = textwrap::Options::new(area.width as usize).initial_indent("[ ] ").subsequent_indent(" ");
let wrap_options_finished = textwrap::Options::new(area.width as usize).initial_indent("[X] ").subsequent_indent(" ");
let options: Vec<Text> = self.0.iter().sorted_by(|a, b| Ord::cmp(a.1, b.1)).map(|(text, is_finished)| {
let (options, color) = if *is_finished {
(wrap_options_finished.clone(), style::Color::DarkGray)
} else {
(wrap_options_unfinished.clone(), style::Color::Green)
};
let contents: Vec<Line> = textwrap::wrap(text, options)
.iter()
.map(|x| { Line::from(x.to_string()).fg(color)}).collect();
Text::from_iter(contents)
}).collect();
let list = List::new(options)
.block(borders)
.style(ratatui::style::Color::White);
Widget::render(list, area, buf);
}
}
pub struct Options<'a>(pub &'a Vec<PossibleResponse>);
impl StatefulWidget for Options<'_> {
type State = ListState;
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer, state: &mut Self::State) {
let borders = Block::bordered().border_style(style::Color::LightGreen).title("Reply Options (Press 'Ctrl+R' to regenerate, Ctrl+Enter to use)");
let wrap_options = textwrap::Options::new(area.width as usize).subsequent_indent("...");
let options: Vec<Text> = self.0.iter().map(|option| {
let mut contents: Vec<Line> = vec![];
if let Some(direction) = &option.stage_direction {
let padded = format!("({})", direction);
let wrapped = textwrap::wrap(&padded, wrap_options.clone());
let wrapped_direction = wrapped
.iter()
.map(|x| { Line::from(x.to_string()).fg(style::Color::Yellow)});
contents.extend(wrapped_direction);
}
let wrapped = textwrap::wrap(&option.text, wrap_options.clone());
let text = wrapped
.iter()
.map(|x| { Line::from(x.to_string())});
contents.extend(text);
Text::from_iter(contents)
}).collect();
let list = List::new(options)
.block(borders)
.style(ratatui::style::Color::White)
.highlight_symbol("> ".fg(ratatui::style::Color::Cyan))
.highlight_style(style::Style::new().bold().bg(style::Color::DarkGray))
.repeat_highlight_symbol(true);
StatefulWidget::render(list, area, buf, state);
}
}
pub struct Conversation<'a>(pub &'a Vec<ConversationEntry>);
impl Conversation<'_> {
fn format_line<'a>(entry: &'a ConversationEntry, max_width: usize) -> Text<'a> {
let prefix = entry.prefix().unwrap_or_default();
let style = entry.prefix_style();
let text_style = entry.text_style();
let avail_width = max_width - prefix.len();
let indent = " ".repeat(prefix.len());
let wrap_options = textwrap::Options::new(avail_width).initial_indent(prefix).subsequent_indent(&indent);
let wrapped: Vec<Line> = textwrap::wrap(&entry.to_string(), wrap_options)
.iter()
.enumerate()
.map(|(idx, s)| {
if idx == 0 {
Line::from_iter([Span::from(prefix).style(style), Span::from(s[prefix.len()..].to_string()).style(text_style)])
} else {
Line::from(s.to_string()).style(text_style)
}
}).collect();
Text::from_iter(wrapped)
}
}
impl<'a> StatefulWidget for Conversation<'a> {
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer, state: &mut Self::State) {
let width = area.width.into();
let items: Vec<Text> = self.0.iter().rev().map(|entry| {
Self::format_line(entry, width)
}).collect();
// TODO: Would be nice to be able to scroll a longer conversation with the scroll wheel, or with page up/down
let list = List::new(items)
.block(Block::bordered().border_style(Color::LightYellow).title("Conversation (Press Tab to select and read Eva's lines aloud)"))
.direction(ListDirection::BottomToTop)
.highlight_symbol("> ")
.highlight_style(Style::new().bold().fg(Color::Cyan));
StatefulWidget::render(list, area, buf, state);
}
type State = ListState;
}
pub struct Volume(pub f64, pub bool);
impl Widget for Volume {
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized
{
const NOISE_FLOOR: f64 = 50.;
let vu_pct = 1.0 - (self.0.abs().min(NOISE_FLOOR) / NOISE_FLOOR);
let volume_color = if self.1 {
if vu_pct >= 0.85 {
style::Color::Red
} else if vu_pct >= 0.60 {
style::Color::Yellow
} else {
style::Color::LightGreen
}
} else {
style::Color::Gray
};
let gauge = Gauge::default()
.ratio(vu_pct)
.use_unicode(true)
.gauge_style(volume_color)
.label(format!("{:.01}dB", self.0));
gauge.render(area, buf);
}
}
pub struct RecordingStatus(pub bool);
impl Widget for RecordingStatus {
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized
{
Line::from_iter(
if self.0 {
[
Span::from(" "),
Span::from(" Recording ").bg(style::Color::LightRed).fg(style::Color::White),
Span::from(" ")
]
} else {
[
Span::from(" "),
Span::from(" Stopped ").bg(style::Color::DarkGray).fg(style::Color::White),
Span::from(" ")
]
}
).render(area, buf);
}
}
pub struct StatusBar<'a>(pub &'a Scene);
impl Widget for StatusBar<'_> {
fn render(self, area: Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized {
let time_remaining: Duration = self.0.direction().time_remaining();
let minutes_remaining = time_remaining.num_seconds() / 60;
let negative = time_remaining.abs() != time_remaining;
let time_style = if minutes_remaining <= 0 || negative {
Style::new().fg(ratatui::style::Color::LightRed).underlined()
} else if minutes_remaining < 5 {
Style::new().fg(ratatui::style::Color::LightRed).bold()
} else if minutes_remaining < 10 {
ratatui::style::Color::Red.into()
} else if minutes_remaining < 25 {
ratatui::style::Color::Yellow.into()
} else if minutes_remaining < 60 {
ratatui::style::Color::Green.into()
} else {
ratatui::style::Color::Blue.into()
};
let formatted_time = if negative {
format!("-{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours().abs(), time_remaining.num_minutes().abs()% 60, time_remaining.num_seconds().abs() % 60)
} else {
format!("{:0>2}:{:0>2}:{:0>2}", time_remaining.num_hours(), time_remaining.num_minutes() % 60, time_remaining.num_seconds() % 60)
};
let artifact_count = self.0.artifact_count;
let artifact_stats = self.0.artifact_stats;
let status_line = Line::from_iter([
Span::from(format!("Playlist: {}", self.0.direction().playlist)).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("{} tracks", self.0.playlist().len())).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("Time Remaining: {}", formatted_time)).style(time_style),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("{} ({}/{}/{}) artifacts recorded", artifact_count, artifact_stats.0, artifact_stats.1, artifact_stats.2)).style(ratatui::style::Color::LightBlue),
Span::from(" | ").style(ratatui::style::Color::DarkGray),
Span::from(format!("{} tokens sacrificed", self.0.tokens_consumed)).style(ratatui::style::Color::LightCyan),
// TODO: Should show the available and consumed context window in terms of tokens here
]);
status_line.render(area, buf);
}
}