Compare commits
32
Commits
8394a076d4
...
33e0b1768f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33e0b1768f | ||
|
|
6e0d986226 | ||
|
|
d69ba43a6b | ||
|
|
ac6cb425ac | ||
|
|
da64a1eea3 | ||
|
|
d83d430ec2 | ||
|
|
8db22b2539 | ||
|
|
0a2a09eb3c | ||
|
|
359941c094 | ||
|
|
b9af9915d1 | ||
|
|
5595b02211 | ||
|
|
59a03eb72c | ||
|
|
af50abd4d3 | ||
|
|
de78737ce7 | ||
|
|
c2c8c99d7a | ||
|
|
aaa1b48b26 | ||
|
|
cc56b09249 | ||
|
|
a049cdacdb | ||
|
|
5784c2440a | ||
|
|
110bc2cbbe | ||
|
|
f269e62e34 | ||
|
|
5aa2631b99 | ||
|
|
b1453b3fbc | ||
|
|
cac1822734 | ||
|
|
45b3ada3e9 | ||
|
|
0396bc12e0 | ||
|
|
2fe1cc3d5c | ||
|
|
44afe5a713 | ||
|
|
96ec57b2d9 | ||
|
|
eeb27656c7 | ||
|
|
7f2dd6f8b2 | ||
|
|
ad90df7767 |
Generated
+417
-4
@@ -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"
|
||||
@@ -503,6 +593,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 +629,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 +693,15 @@ 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 +814,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 +848,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 +885,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 +1129,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,13 +1187,16 @@ dependencies = [
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"dotenv",
|
||||
"enumset",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"hound",
|
||||
"iref 4.0.0",
|
||||
"jack",
|
||||
"json-ld",
|
||||
"log",
|
||||
"minify",
|
||||
"musicbrainz_rs",
|
||||
"oximedia-metering",
|
||||
"ratatui",
|
||||
"rc-writer",
|
||||
@@ -1013,6 +1209,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sqlite",
|
||||
"static-iref",
|
||||
"static_cell",
|
||||
"tempfile",
|
||||
"textwrap",
|
||||
"throbber-widgets-tui",
|
||||
@@ -1021,6 +1218,27 @@ dependencies = [
|
||||
"tui-skeleton",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "eventsource-stream"
|
||||
version = "0.2.3"
|
||||
@@ -1087,6 +1305,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 +1411,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"
|
||||
@@ -1294,6 +1532,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 +1623,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"
|
||||
@@ -2293,9 +2543,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 +2562,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 +2659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2409,6 +2680,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 +2978,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 +3192,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"
|
||||
@@ -3542,6 +3859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3895,6 +4213,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 +4285,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 +4309,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 +4430,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"
|
||||
@@ -4395,12 +4749,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 +4765,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"
|
||||
@@ -4733,6 +5099,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 +5143,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 +5170,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"
|
||||
|
||||
@@ -11,13 +11,16 @@ 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"] }
|
||||
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"
|
||||
ratatui = "0.30.0"
|
||||
rc-writer = "1.1.10"
|
||||
@@ -30,6 +33,7 @@ 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"
|
||||
tempfile = "3.27.0"
|
||||
textwrap = "0.16.2"
|
||||
throbber-widgets-tui = "0.11.0"
|
||||
|
||||
+7559
File diff suppressed because it is too large
Load Diff
+33
@@ -0,0 +1,33 @@
|
||||
# UI
|
||||
[ ] Conversation options that exceed a certain predicted amount of TTS time (1-2 seconds) should get highlighted differently
|
||||
[ ] 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"
|
||||
[ ] Mixxx playlists other than the regular episode
|
||||
[ ] Realtime updates to the mixxx playlist
|
||||
[ ] Include mixxx ratings into metadata
|
||||
[ ] somehow optimize the massive json blob of data we send?
|
||||
|
||||
# Refactoring
|
||||
[ ] Move tools into a sub-module
|
||||
[ ] Debug log should show up into the UI in a separate list
|
||||
[ ] Use pipewire over jack
|
||||
[ ] 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.
|
||||
[ ] 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?
|
||||
|
||||
# Next Episode
|
||||
[ ] Ambient SFX
|
||||
[ ] Musicbrainz artifacts
|
||||
[ ] Automatic quiet detection instead of push-to-talk
|
||||
[ ] Preload musicbrainz data from playlist with a command
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::artifacts::{Album, Artifact, Artist, SourceID};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
|
||||
pub struct BandcampQueryArgs {
|
||||
pub query: String
|
||||
}
|
||||
|
||||
impl Into<Artifact> for bandcamp::Artist {
|
||||
fn into(self) -> Artifact {
|
||||
Artifact::Artist(Artist { name: self.name, bio: self.bio, location: self.location, sources: HashSet::from([SourceID::Bandcamp(self.id)])})
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<Artifact> for bandcamp::Album {
|
||||
fn into(self) -> Artifact {
|
||||
Artifact::Album(Album {
|
||||
about: self.about,
|
||||
title: self.title,
|
||||
artist: self.band.name,
|
||||
credits: self.credits,
|
||||
release_date: Some(self.release_date),
|
||||
sources: HashSet::from([SourceID::Bandcamp(self.id)])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::{collections::HashSet, process::{Command, Stdio}};
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::artifacts::{Artifact, SourceID, Track};
|
||||
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone, JsonSchema)]
|
||||
pub struct BeatsQueryArgs {
|
||||
artist: Option<String>,
|
||||
album: Option<String>,
|
||||
genre: Option<String>,
|
||||
title: Option<String>,
|
||||
year: Option<u32>
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct BeetsTrack {
|
||||
album: String,
|
||||
artist: String,
|
||||
genres: Option<Vec<String>>,
|
||||
label: Option<String>,
|
||||
title: String,
|
||||
year: u32,
|
||||
mb_trackid: Option<String>
|
||||
}
|
||||
|
||||
impl Into<Artifact> for BeetsTrack {
|
||||
fn into(self) -> Artifact {
|
||||
let sources = if let Some(mbid) = self.mb_trackid {
|
||||
HashSet::from([SourceID::Beets, SourceID::Musicbrainz(mbid)])
|
||||
} else {
|
||||
HashSet::from([SourceID::Beets])
|
||||
};
|
||||
Artifact::Track(Track {
|
||||
title: self.title,
|
||||
label: self.label,
|
||||
year: Some(self.year),
|
||||
genres: self.genres.unwrap_or_default(),
|
||||
album: Some(self.album),
|
||||
artist: Some(self.artist),
|
||||
bpm: None,
|
||||
sources
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BeatsQueryArgs {
|
||||
pub fn execute(self) -> Result<Vec<Artifact>, ()> {
|
||||
let mut beets_cmd = Command::new("beet");
|
||||
beets_cmd.args(["export", "-f", "json", "-i", "title,label,year,genres,album,artist,mb_trackid"]);
|
||||
let mut valid = false;
|
||||
if let Some(artist) = self.artist {
|
||||
beets_cmd.arg(format!("artist:{}", artist));
|
||||
valid = true;
|
||||
}
|
||||
if let Some(genre) = self.genre {
|
||||
beets_cmd.arg(format!("genre:{}", genre));
|
||||
valid = true;
|
||||
}
|
||||
if let Some(album) = self.album {
|
||||
beets_cmd.arg(format!("album:{}", album));
|
||||
valid = true;
|
||||
}
|
||||
if let Some(title) = self.title {
|
||||
beets_cmd.arg(format!("title:{}", title));
|
||||
valid = true;
|
||||
}
|
||||
if let Some(year) = self.year {
|
||||
beets_cmd.arg(format!("year:{}", year));
|
||||
valid = true;
|
||||
}
|
||||
|
||||
if !valid {
|
||||
log::warn!("Tried to execute an empty beets query");
|
||||
return Err(())
|
||||
}
|
||||
|
||||
log::debug!("Executing beets: {:?}", beets_cmd);
|
||||
if let Ok(output) = beets_cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap().wait_with_output() {
|
||||
match serde_json::from_str::<Vec<BeetsTrack>>(str::from_utf8(&output.stdout).unwrap()) {
|
||||
Ok(track) => Ok(track.into_iter().map(|t| { t.into()}).collect()),
|
||||
Err(err) => {
|
||||
log::error!("Failed to decode beets json: {:?}", err);
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Unable to execute query!");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use sqlite::OpenFlags;
|
||||
|
||||
use crate::artifacts::{Album, Artifact, Artist, SourceID, Track};
|
||||
|
||||
#[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(());
|
||||
|
||||
impl MixxxDB {
|
||||
pub fn load(playlist_name: &str) -> Result<Vec<Artifact>, MixxxError> {
|
||||
let mut ret = vec![];
|
||||
log::info!("Loading Mixxx playlist {}", playlist_name);
|
||||
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, 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(Artifact::Track(Track {
|
||||
artist: Some(artist.into()),
|
||||
album: Some(album.into()),
|
||||
title: title.into(),
|
||||
bpm: Some(bpm),
|
||||
sources: HashSet::from([SourceID::Mixxx]),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
ret.push(Artifact::Album(Album {
|
||||
artist: artist.into(),
|
||||
title: album.into(),
|
||||
sources: HashSet::from([SourceID::Mixxx]),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
ret.push(Artifact::Artist(Artist {
|
||||
name: artist.into(),
|
||||
sources: HashSet::from([SourceID::Mixxx]),
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod bandcamp;
|
||||
pub mod mixxx;
|
||||
pub mod beets;
|
||||
pub mod musicbrainz;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum SourceID {
|
||||
Bandcamp(u64),
|
||||
Musicbrainz(String),
|
||||
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>,
|
||||
|
||||
pub sources: HashSet<SourceID>
|
||||
}
|
||||
|
||||
impl PartialEq for Artist {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
if self.name != other.name {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn ne(&self, other: &Self) -> bool {
|
||||
!self.eq(other)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, 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>>,
|
||||
|
||||
pub sources: HashSet<SourceID>
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Track {
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub year: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(default)]
|
||||
pub genres: Vec<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 = "Option::is_none")]
|
||||
pub bpm: Option<f64>,
|
||||
|
||||
pub sources: HashSet<SourceID>
|
||||
}
|
||||
|
||||
impl PartialEq for Track {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
if self.title != other.title {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.artist.is_some() && self.artist != other.artist {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.album.is_some() && self.album != other.album {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn ne(&self, other: &Self) -> bool {
|
||||
!self.eq(other)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum Artifact {
|
||||
Artist(Artist),
|
||||
Album(Album),
|
||||
Track(Track)
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
for src in &$that.sources {
|
||||
$this.sources.insert(src.clone());
|
||||
}
|
||||
)+
|
||||
}
|
||||
}
|
||||
|
||||
impl Merge for Artifact {
|
||||
fn merge(&mut self, other: Self) {
|
||||
if *self != other {
|
||||
return;
|
||||
}
|
||||
|
||||
match (self, other) {
|
||||
(Self::Track(this_track), Self::Track(that_track)) => {
|
||||
merge_fields!(this_track, that_track, album, label, year, artist, bpm);
|
||||
},
|
||||
(Self::Album(this_album), Self::Album(that_album)) => {
|
||||
merge_fields!(this_album, that_album, about, credits, release_date);
|
||||
},
|
||||
(Self::Artist(this_artist), Self::Artist(that_artist)) => {
|
||||
merge_fields!(this_artist, that_artist, bio, location);
|
||||
},
|
||||
_ => ()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use musicbrainz_rs::entity::recording::Recording;
|
||||
use musicbrainz_rs::prelude::*;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::artifacts::{Album, Artifact, Artist, SourceID, Track};
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct MusicbrainzQueryArgs {
|
||||
pub mb_ids: Vec<String>
|
||||
}
|
||||
|
||||
pub async fn search_artifacts(query: MusicbrainzQueryArgs) -> Result<Vec<Artifact>, musicbrainz_rs::ApiEndpointError> {
|
||||
let mut ret = vec![];
|
||||
for mbid in query.mb_ids {
|
||||
let track = Recording::fetch()
|
||||
.id(&mbid)
|
||||
.with_releases().with_artists().with_annotations().execute_async().await?;
|
||||
|
||||
for release in track.releases.unwrap_or_default() {
|
||||
log::debug!("Found new release: {:?}", release);
|
||||
let first_artist = release.artist_credit.unwrap_or_default().first().unwrap().clone();
|
||||
ret.push(Artifact::Album(Album {
|
||||
title: release.title.clone(),
|
||||
artist: first_artist.name.clone(),
|
||||
about: release.annotation,
|
||||
sources: HashSet::from([SourceID::Musicbrainz(release.id.clone())]),
|
||||
..Default::default()
|
||||
}));
|
||||
ret.push(Artifact::Track(Track {
|
||||
album: Some(release.title),
|
||||
title: track.title.clone(),
|
||||
artist: Some(first_artist.artist.name.clone()),
|
||||
sources: HashSet::from([SourceID::Musicbrainz(release.id.clone())]),
|
||||
..Default::default()
|
||||
}));
|
||||
ret.push(Artifact::Artist(Artist {
|
||||
name: first_artist.name,
|
||||
bio: first_artist.artist.annotation,
|
||||
location: first_artist.artist.area.and_then(|area| { Some(area.name) }),
|
||||
sources: HashSet::from([SourceID::Musicbrainz(release.id)]),
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
}
|
||||
+61
-33
@@ -39,10 +39,17 @@ pub struct TtsOutStream {
|
||||
pub sample_rate: u32
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SfxOutStream {
|
||||
pub sink: mpsc::Sender<Vec<f32>>,
|
||||
pub sample_rate: u32
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
struct AudioConfig {
|
||||
mic_in_connections: Vec<String>,
|
||||
tts_out_connections: Vec<String>
|
||||
tts_out_connections: Vec<String>,
|
||||
sfx_out_connections: Vec<String>,
|
||||
}
|
||||
|
||||
impl AudioConfig {
|
||||
@@ -60,7 +67,7 @@ struct Notify {
|
||||
config: AudioConfig,
|
||||
mic_port: jack::Port<jack::Unowned>,
|
||||
tts_port: jack::Port<jack::Unowned>,
|
||||
log: mpsc::Sender<String>
|
||||
sfx_port: jack::Port<jack::Unowned>,
|
||||
}
|
||||
|
||||
impl NotificationHandler for Notify {
|
||||
@@ -78,16 +85,18 @@ impl NotificationHandler for Notify {
|
||||
("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)
|
||||
} else if port_a == self.sfx_port {
|
||||
("SFX output", port_b, &mut self.config.sfx_out_connections)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(port_name) = other_port.name() {
|
||||
if are_connected {
|
||||
self.log.blocking_send(format!("{} connected to {}", stream_name, port_name)).unwrap();
|
||||
log::info!("{} connected to {}", stream_name, port_name);
|
||||
target_cfg.push(port_name);
|
||||
} else {
|
||||
self.log.blocking_send(format!("{} disconnected from {}", stream_name, port_name)).unwrap();
|
||||
log::info!("{} disconnected from {}", stream_name, port_name);
|
||||
target_cfg.retain(|x| { x != &port_name} );
|
||||
}
|
||||
|
||||
@@ -97,37 +106,45 @@ impl NotificationHandler for Notify {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_audio_input(messages: &mpsc::Sender<String>) -> (AudioInputControl, MicStream, TtsOutStream) {
|
||||
pub async fn start_audio_input() -> (AudioInputControl, MicStream, TtsOutStream, SfxOutStream) {
|
||||
|
||||
let (exit_tx, exit_rx) = oneshot::channel();
|
||||
|
||||
let config = AudioConfig::load();
|
||||
|
||||
let (mic_audio_sink, mic_audio_src) = mpsc::channel(32);
|
||||
let (sfx_audio_sink, mut sfx_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 mut sfx_port = client.register_port("sfx-out", AudioOut::default()).unwrap();
|
||||
let rate = client.sample_rate();
|
||||
|
||||
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, connections) in [
|
||||
(&tts_port, &config.tts_out_connections),
|
||||
(&sfx_port, &config.sfx_out_connections),
|
||||
] {
|
||||
for peer_name in connections {
|
||||
if let Some(peer) = client.port_by_name(peer_name) {
|
||||
if let Err(err) = client.connect_ports(port, &peer) {
|
||||
log::error!("Failed to reconnect {} to {}", port.name().unwrap(), peer_name);
|
||||
} else {
|
||||
log::info!("Reconnected {} to {}", port.name().unwrap(), peer_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
for (port, connections) in [
|
||||
(&mic_port, &config.mic_in_connections)
|
||||
] {
|
||||
for peer_name in connections {
|
||||
if let Some(peer) = client.port_by_name(peer_name) {
|
||||
client.connect_ports(&peer, port).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,12 +152,14 @@ pub async fn start_audio_input(messages: &mpsc::Sender<String>) -> (AudioInputCo
|
||||
config,
|
||||
mic_port: mic_port.clone_unowned(),
|
||||
tts_port: tts_port.clone_unowned(),
|
||||
log: messages.clone()
|
||||
sfx_port: sfx_port.clone_unowned()
|
||||
};
|
||||
|
||||
let mut meter = VuMeter::new(rate.into(), 1, None);
|
||||
let mut tts_output_buf = vec![];
|
||||
let mut sfx_output_buf = vec![];
|
||||
tts_output_buf.reserve(1024);
|
||||
sfx_output_buf.reserve(1024);
|
||||
|
||||
let handler = jack::contrib::ClosureProcessHandler::new(move |_client, scope| {
|
||||
if mic_port.connected_count().unwrap() > 0 {
|
||||
@@ -150,6 +169,7 @@ pub async fn start_audio_input(messages: &mpsc::Sender<String>) -> (AudioInputCo
|
||||
|
||||
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
|
||||
@@ -159,23 +179,28 @@ pub async fn start_audio_input(messages: &mpsc::Sender<String>) -> (AudioInputCo
|
||||
});
|
||||
}
|
||||
|
||||
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.);
|
||||
}
|
||||
for (src, output, port) in [
|
||||
(&mut tts_audio_src, &mut tts_output_buf, &mut tts_port),
|
||||
(&mut sfx_audio_src, &mut sfx_output_buf, &mut sfx_port)
|
||||
] {
|
||||
if let Ok(mut next_outbuf) = src.try_recv() {
|
||||
output.append(&mut next_outbuf);
|
||||
}
|
||||
|
||||
outbuf.copy_from_slice(&next_segment);
|
||||
}
|
||||
if port.connected_count().unwrap() > 0 && !output.is_empty() {
|
||||
let outbuf = port.as_mut_slice(scope);
|
||||
let mut next_segment: Vec<f32> = output.drain(0..(outbuf.len()).min(output.len())).collect();
|
||||
let underrun = outbuf.len() - next_segment.len();
|
||||
if underrun > 0 {
|
||||
for _ in 0..underrun {
|
||||
next_segment.push(0.);
|
||||
}
|
||||
}
|
||||
|
||||
outbuf.copy_from_slice(&next_segment);
|
||||
}
|
||||
|
||||
}
|
||||
jack::Control::Continue
|
||||
});
|
||||
|
||||
@@ -196,5 +221,8 @@ pub async fn start_audio_input(messages: &mpsc::Sender<String>) -> (AudioInputCo
|
||||
}, TtsOutStream {
|
||||
sample_rate: rate,
|
||||
sink: tts_audio_sink
|
||||
}, SfxOutStream {
|
||||
sample_rate: rate,
|
||||
sink: sfx_audio_sink
|
||||
})
|
||||
}
|
||||
+50
-519
@@ -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::{audio::start_audio_input, scene::{Scenery, StageDirection}, tts::start_tts, ui::Ui};
|
||||
|
||||
mod scene;
|
||||
mod events;
|
||||
@@ -21,6 +18,9 @@ mod transcription;
|
||||
mod tts;
|
||||
mod prediction;
|
||||
mod audio;
|
||||
mod artifacts;
|
||||
mod ui;
|
||||
mod widgets;
|
||||
|
||||
// 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,480 +51,6 @@ 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 {
|
||||
@@ -540,6 +66,24 @@ impl SaveData {
|
||||
}
|
||||
}
|
||||
|
||||
struct SysMessageLogger<T>(Arc<tokio::sync::mpsc::UnboundedSender<String>>, 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());
|
||||
write!(self.1.lock().unwrap(), "{}\n", msg).unwrap();
|
||||
if record.level() <= LevelFilter::Info {
|
||||
self.0.send(msg).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
|
||||
@@ -553,6 +97,13 @@ async fn main() {
|
||||
println!("Panic: {}", msg);
|
||||
}));
|
||||
|
||||
let (sys_message_sink, sys_message_src) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
static LOGGER: StaticCell<SysMessageLogger<std::fs::File>> = StaticCell::new();
|
||||
let logger = LOGGER.init(SysMessageLogger(Arc::new(sys_message_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 +113,37 @@ 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();
|
||||
ret
|
||||
} else {
|
||||
sys_message_sink.send("Could not load saved session!".into()).await.unwrap();
|
||||
SaveData::default()
|
||||
match serde_json::from_str(&save_data) {
|
||||
Ok(ret) => {
|
||||
log::info!("Loaded session from save.json");
|
||||
ret
|
||||
},
|
||||
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 prediction_ctrl = prediction::start_prediction(saved_session, sys_message_src).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 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 mut app = Ui::new(prediction_ctrl, audio_ctrl, transcription_ctrl, tts_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 +155,7 @@ async fn main() {
|
||||
|
||||
app.on_event(event).await;
|
||||
},
|
||||
_ => ()
|
||||
_ => break
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+221
-130
@@ -1,18 +1,28 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
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 tokio::sync::{RwLock, mpsc, watch};
|
||||
|
||||
use crate::{SaveData, scene::{Artifact, ConversationEntry, Scene, Scenery, StageActions, StageDirection}};
|
||||
use crate::{SaveData, artifacts::{self, Album, Artifact, Artist, Merge, SourceID, Track, bandcamp::BandcampQueryArgs, beets::BeatsQueryArgs, mixxx::MixxxDB, musicbrainz::{MusicbrainzQueryArgs, search_artifacts}}, scene::{Scene, Scenery, StageDirection, conversation::ConversationEntry}};
|
||||
|
||||
|
||||
const SYSTEM_PROMPT: &str = include_str!("system-prompt.txt");
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PredictionAction {
|
||||
ConversationAppend(ConversationEntry),
|
||||
SetPlaylist(String),
|
||||
GeneratePredictions,
|
||||
SetNarrative(String),
|
||||
SetShowEndTime(DateTime<Utc>)
|
||||
}
|
||||
|
||||
|
||||
#[derive(JsonSchema, Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct PossibleResponse {
|
||||
pub text: String,
|
||||
@@ -31,8 +41,11 @@ struct Session {
|
||||
header_message: ChatCompletionRequestMessage,
|
||||
messages: Vec<ChatCompletionRequestMessage>,
|
||||
reply_options: GeneratedResponses,
|
||||
direction: StageDirection,
|
||||
scenery: Scenery,
|
||||
tokens_consumed: usize
|
||||
tokens_consumed: usize,
|
||||
activity_notify: watch::Sender<bool>,
|
||||
scene_sink: watch::Sender<Scene>
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
|
||||
@@ -46,44 +59,6 @@ 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>,
|
||||
@@ -91,7 +66,7 @@ struct ToolResults {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn from_initial_messages(messages: Vec<ChatCompletionRequestMessage>, scenery: Scenery) -> Self {
|
||||
fn new(scene_sink: watch::Sender<Scene>, messages: Vec<ChatCompletionRequestMessage>, scenery: Scenery, direction: StageDirection, activity_notify: watch::Sender<bool>) -> Self {
|
||||
let mut conversation = vec![];
|
||||
for msg in &messages {
|
||||
if let Ok(conversation_msg) = msg.clone().try_into() {
|
||||
@@ -106,13 +81,10 @@ impl Session {
|
||||
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());
|
||||
direction,
|
||||
tokens_consumed: 0,
|
||||
activity_notify,
|
||||
scene_sink
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,34 +94,55 @@ impl Session {
|
||||
StageEvent::StageDirection(text) => ConversationEntry::StageDirection(text)
|
||||
};
|
||||
ToolResults {
|
||||
result: Some(msg.to_string()),
|
||||
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()));
|
||||
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) => {
|
||||
let result: BandcampResult = bandcamp::fetch_artist(data.artist_id).await.unwrap().into();
|
||||
json_results.push(Artifact::Bandcamp(result));
|
||||
/*let result = Artifact::Artist(Artist {
|
||||
name: data.name,
|
||||
location: data.location,
|
||||
..Default::default()
|
||||
});*/
|
||||
let result = bandcamp::fetch_artist(data.artist_id).await.unwrap().into();
|
||||
json_results.push(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 result = bandcamp::fetch_album(data.band_id, data.album_id).await.unwrap().into();
|
||||
/*let result = Artifact::Album(Album {
|
||||
title: data.name,
|
||||
artist: data.band_name,
|
||||
..Default::default()
|
||||
});*/
|
||||
json_results.push(result);
|
||||
},
|
||||
SearchResultItem::Track(data) => {
|
||||
let result = Artifact::Track(Track {
|
||||
title: data.name,
|
||||
artist: Some(data.band_name),
|
||||
album: data.album_name,
|
||||
sources: HashSet::from([SourceID::Bandcamp(data.track_id)]),
|
||||
..Default::default()
|
||||
});
|
||||
json_results.push(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()));
|
||||
messages.push(ConversationEntry::ShipComputer(format!("Bandcamp relay scan for '{}' complete. {} artifacts added to the archive.", args.query, artifact_count).into()));
|
||||
|
||||
self.scenery.artifacts.append(&mut json_results);
|
||||
self.scenery.artifacts.merge(json_results);
|
||||
|
||||
ToolResults {
|
||||
result: Some(format!("{} artifacts were added to the archive.", artifact_count)),
|
||||
@@ -159,26 +152,10 @@ impl Session {
|
||||
|
||||
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()));
|
||||
log::debug!("Executing beets query {:?}", args);
|
||||
if let Ok(output) = args.clone().execute() {
|
||||
messages.push(ConversationEntry::ShipComputer(format!("Found {} artifacts with archive query {:?}", output.len(), args)));
|
||||
self.scenery.artifacts.merge(output);
|
||||
} else {
|
||||
messages.push(ConversationEntry::ShipComputer("Unable to execute query!".into()));
|
||||
};
|
||||
@@ -189,33 +166,46 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
async fn tool_musicbrainz_fetch_tracks(&mut self, args: MusicbrainzQueryArgs) -> ToolResults {
|
||||
log::debug!("Executing musicbrainz fetch for {:?}", args);
|
||||
let results = search_artifacts(args).await.unwrap();
|
||||
|
||||
let msg = format!("Found {} results via Musicbrainz relay search.", results.len());
|
||||
|
||||
self.scenery.artifacts.merge(results);
|
||||
|
||||
ToolResults {
|
||||
result: Some(msg.clone()),
|
||||
messages: vec![ConversationEntry::ShipComputer(msg)]
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
serde_json::json!({
|
||||
"direction": direction,
|
||||
"scenery": self.scenery
|
||||
}).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) {
|
||||
async fn regenerate_options(&mut self) {
|
||||
self.reply_options.responses.clear();
|
||||
self.refresh();
|
||||
self.activity_notify.send_if_modified(|x| { if !*x { *x = true; true } else { false }});
|
||||
loop {
|
||||
let full_conversation = self.generate_conversation(direction);
|
||||
let full_conversation = self.generate_conversation(&self.direction);
|
||||
|
||||
let tools = vec![
|
||||
ChatCompletionTools::Function(ChatCompletionTool {
|
||||
@@ -225,6 +215,10 @@ impl Session {
|
||||
.parameters(schema_for!(StageEventArgs))
|
||||
.build().unwrap()
|
||||
}),
|
||||
// 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 relay search should try to grab first from beets, then musicbrainz, then from bandcamp.
|
||||
// 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"
|
||||
ChatCompletionTools::Function(ChatCompletionTool {
|
||||
function: FunctionObjectArgs::default()
|
||||
.name("archive_query")
|
||||
@@ -238,8 +232,18 @@ impl Session {
|
||||
.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()
|
||||
}),
|
||||
ChatCompletionTools::Function(ChatCompletionTool {
|
||||
function: FunctionObjectArgs::default()
|
||||
.name("musicbrainz_track_search")
|
||||
.description("Fetches metadata from bandcamp for the given musicbrainz recording IDs (mbid)")
|
||||
.parameters(schema_for!(MusicbrainzQueryArgs))
|
||||
.build().unwrap()
|
||||
})
|
||||
// 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.
|
||||
];
|
||||
log::debug!("Sending request..");
|
||||
let request = CreateChatCompletionRequestArgs::default()
|
||||
.messages(full_conversation)
|
||||
.model("gpt-5.4-mini")
|
||||
@@ -261,18 +265,19 @@ impl Session {
|
||||
|
||||
if let Some(usage) = response.usage {
|
||||
self.tokens_consumed += usage.total_tokens as usize;
|
||||
log::debug!("{} tokens cast into the void", usage.total_tokens);
|
||||
}
|
||||
|
||||
if let Some(message) = response.choices.first() {
|
||||
|
||||
match message.finish_reason {
|
||||
Some(FinishReason::ContentFilter) => {
|
||||
self.insert_conversation(ConversationEntry::SystemMessage("Content filter triggered.".into()));
|
||||
return;
|
||||
log::error!("Content filter triggered.");
|
||||
break;
|
||||
},
|
||||
Some(FinishReason::Length) => {
|
||||
self.insert_conversation(ConversationEntry::SystemMessage("Maximum token count exceeded!".into()));
|
||||
return;
|
||||
log::error!("Maximum token count exceeded!");
|
||||
break;
|
||||
},
|
||||
_ => ()
|
||||
}
|
||||
@@ -292,6 +297,7 @@ impl Session {
|
||||
"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,
|
||||
"musicbrainz_track_search" => self.tool_musicbrainz_fetch_tracks(serde_json::from_str(args).unwrap()).await,
|
||||
_ => unreachable!()
|
||||
};
|
||||
results.push((&call.id, tool_result));
|
||||
@@ -317,19 +323,24 @@ impl Session {
|
||||
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;
|
||||
break;
|
||||
} else {
|
||||
self.insert_conversation(ConversationEntry::SystemMessage("Received invalid JSON! Trying again.".into()));
|
||||
log::info!("Received invalid JSON! Trying again.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.insert_conversation(ConversationEntry::SystemMessage("No messages were received! Trying again.".into()));
|
||||
log::info!("No messages were received! Trying again.");
|
||||
}
|
||||
|
||||
self.refresh();
|
||||
}
|
||||
self.activity_notify.send_if_modified(|x| { if *x { *x = false; true } else { false }});
|
||||
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
fn as_scene(&self) -> Scene {
|
||||
Scene::new(self.reply_options.clone(), self.conversation.clone(), self.scenery.clone(), self.tokens_consumed)
|
||||
Scene::new(self.reply_options.clone(), self.conversation.clone(), self.scenery.clone(), self.tokens_consumed, self.direction.clone())
|
||||
}
|
||||
|
||||
fn insert_conversation(&mut self, entry: ConversationEntry) {
|
||||
@@ -338,50 +349,130 @@ impl Session {
|
||||
if let Ok(next_msg) = entry.try_into() {
|
||||
self.messages.push(next_msg);
|
||||
}
|
||||
|
||||
self.refresh();
|
||||
}
|
||||
|
||||
fn refresh(&self) {
|
||||
self.scene_sink.send(self.as_scene()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
#[derive(Debug)]
|
||||
pub struct SessionControl {
|
||||
event_sink: mpsc::Sender<PredictionAction>,
|
||||
scene_watch: watch::Receiver<Scene>,
|
||||
activity_watch: watch::Receiver<bool>
|
||||
}
|
||||
|
||||
let mut session = Session::from_initial_messages(initial_messages, scenery);
|
||||
#[derive(Debug)]
|
||||
pub enum SessionUpdate {
|
||||
Scene(Scene),
|
||||
Thinking(bool)
|
||||
}
|
||||
|
||||
impl SessionControl {
|
||||
pub async fn insert(&self, action: PredictionAction) {
|
||||
self.event_sink.send(action).await.unwrap();
|
||||
}
|
||||
|
||||
pub async fn regenerate_options(&self) {
|
||||
self.insert(PredictionAction::GeneratePredictions).await;
|
||||
}
|
||||
|
||||
pub async fn changed(&mut self) -> SessionUpdate {
|
||||
tokio::select! {
|
||||
_ = self.activity_watch.changed() => {
|
||||
SessionUpdate::Thinking(*self.activity_watch.borrow_and_update())
|
||||
},
|
||||
_ = self.scene_watch.changed() => {
|
||||
SessionUpdate::Scene(self.scene_watch.borrow_and_update().clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_prediction(saved_session: SaveData, mut messages: tokio::sync::mpsc::UnboundedReceiver<String>) -> SessionControl {
|
||||
let (prediction_in, prediction_out) = tokio::sync::watch::channel(Scene::default());
|
||||
let (activity_notify_sink, activity_notify_src) = tokio::sync::watch::channel(false);
|
||||
|
||||
let (action_sink, mut action_src) = mpsc::channel(5);
|
||||
|
||||
let session = Session::new(prediction_in, saved_session.messages, saved_session.scenery, saved_session.direction, activity_notify_sink);
|
||||
|
||||
// Send the initial scene to the UI, after we have loaded the session from the first messages.
|
||||
prediction_in.send(session.as_scene()).unwrap();
|
||||
session.refresh();
|
||||
|
||||
let shared_session = Arc::new(RwLock::new(session));
|
||||
|
||||
let log_session = Arc::clone(&shared_session);
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(msg) = messages.recv().await {
|
||||
log_session.write().await.insert_conversation(ConversationEntry::SystemMessage(msg));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(prediction_request_in, prediction_out)
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Some(evt) = action_src.recv().await {
|
||||
let mut session = shared_session.write().await;
|
||||
let do_regen = match evt {
|
||||
PredictionAction::ConversationAppend(msg) => {
|
||||
let do_regen = match msg {
|
||||
ConversationEntry::Eva(_) | ConversationEntry::ShipComputer(_) | ConversationEntry::User(_) => true,
|
||||
_ => false
|
||||
};
|
||||
session.insert_conversation(msg);
|
||||
|
||||
do_regen
|
||||
},
|
||||
PredictionAction::SetPlaylist(playlist_name) => {
|
||||
match MixxxDB::load(&playlist_name) {
|
||||
Err(err) => log::info!("Failed to load mixxx playlist: {:?}.", err),
|
||||
Ok(playlist) => {
|
||||
session.scenery.artifacts.merge(playlist.clone());
|
||||
session.scenery.current_playlist = playlist;
|
||||
session.direction.playlist = playlist_name;
|
||||
log::info!("Mixxx playlist reloaded.");
|
||||
}
|
||||
}
|
||||
false
|
||||
},
|
||||
PredictionAction::GeneratePredictions => {
|
||||
true
|
||||
},
|
||||
PredictionAction::SetNarrative(narrative) => {
|
||||
session.direction.narrative = narrative;
|
||||
log::info!("Updated stage direction narrative");
|
||||
true
|
||||
},
|
||||
PredictionAction::SetShowEndTime(end_time) => {
|
||||
session.direction.end_time = end_time;
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let save_data = SaveData {
|
||||
direction: session.direction.clone(),
|
||||
messages: session.messages.clone(),
|
||||
scenery: session.scenery.clone()
|
||||
};
|
||||
|
||||
save_data.save();
|
||||
|
||||
if do_regen {
|
||||
drop(session);
|
||||
shared_session.write().await.regenerate_options().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
SessionControl {
|
||||
event_sink: action_sink,
|
||||
scene_watch: prediction_out,
|
||||
activity_watch: activity_notify_src
|
||||
}
|
||||
}
|
||||
-160
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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)]
|
||||
pub enum ConversationEntry {
|
||||
User(String),
|
||||
Eva(String),
|
||||
ShipComputer(String),
|
||||
StageDirection(String),
|
||||
#[serde(skip)]
|
||||
SystemMessage(String)
|
||||
}
|
||||
|
||||
impl ConversationEntry {
|
||||
pub fn prefix(&self) -> Option<&str> {
|
||||
match self {
|
||||
ConversationEntry::Eva(_) => Some("Eva: "),
|
||||
ConversationEntry::User(_) => Some("Argee: "),
|
||||
ConversationEntry::ShipComputer(_) => Some("Ship Computer: "),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefix_style(&self) -> Style {
|
||||
match self {
|
||||
ConversationEntry::Eva(_) => Style::new().fg(style::Color::Cyan),
|
||||
ConversationEntry::User(_) => Style::new().fg(style::Color::Magenta),
|
||||
ConversationEntry::ShipComputer(_) => Style::new().fg(style::Color::Red),
|
||||
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
|
||||
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_style(&self) -> Style {
|
||||
match self {
|
||||
ConversationEntry::StageDirection(_) => Style::new().fg(style::Color::Yellow),
|
||||
ConversationEntry::SystemMessage(_) => Style::new().fg(style::Color::DarkGray),
|
||||
_ => Style::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for ConversationEntry {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
ConversationEntry::Eva(text) => text,
|
||||
ConversationEntry::ShipComputer(text) => text,
|
||||
ConversationEntry::StageDirection(text) => text,
|
||||
ConversationEntry::SystemMessage(text) => text,
|
||||
ConversationEntry::User(text) => text
|
||||
}.clone()
|
||||
}
|
||||
}
|
||||
|
||||
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 = ();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{artifacts::Artifact, prediction::{GeneratedResponses, PossibleResponse}, scene::conversation::ConversationEntry};
|
||||
|
||||
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, Serialize, Deserialize, Clone)]
|
||||
pub struct Scenery {
|
||||
pub artifacts: Vec<Artifact>,
|
||||
pub current_playlist: Vec<Artifact>
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct Scene {
|
||||
reply_options: GeneratedResponses,
|
||||
conversation: Vec<ConversationEntry>,
|
||||
direction: StageDirection,
|
||||
pub tokens_consumed: usize,
|
||||
scenery: Scenery
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub fn new(reply_options: GeneratedResponses, conversation: Vec<ConversationEntry>, scenery: Scenery, tokens_consumed: usize, direction: StageDirection) -> Self {
|
||||
Self {
|
||||
reply_options,
|
||||
conversation,
|
||||
scenery,
|
||||
tokens_consumed,
|
||||
direction
|
||||
}
|
||||
}
|
||||
|
||||
pub fn direction(&self) -> &StageDirection {
|
||||
&self.direction
|
||||
}
|
||||
|
||||
pub fn scenery(&self) -> &Scenery {
|
||||
&self.scenery
|
||||
}
|
||||
|
||||
pub fn conversation(&self) -> &Vec<ConversationEntry> {
|
||||
&self.conversation
|
||||
}
|
||||
|
||||
pub fn reply_options(&self) -> &Vec<PossibleResponse> {
|
||||
&self.reply_options.responses
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,17 @@ Along the way, you have the opportunity to invent lore and backstory for yoursel
|
||||
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.
|
||||
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.
|
||||
You must provide at least one parameter when calling this tool; it is wasteful to call it without any arguments.
|
||||
|
||||
Another tool function named "musicbrainz_track_search" can be given a list of musicbrainz IDs (mbids), which will substantially expand the information available in the ship's artifact library.
|
||||
You are able to use this function whenever it might be helpful to look up missing albums, tracks, or artists.
|
||||
You should immediately run this tool against any new or unfamiliar musicbrainz IDs for tracks that get added to the list of artifacts available.
|
||||
|
||||
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.
|
||||
|
||||
Queries to bandcamp are somewhat expensive, so you should check with the archive and musicbrainz first if you haven't already.
|
||||
|
||||
# Scene
|
||||
The show features Argee, the main character of the show.
|
||||
|
||||
@@ -42,6 +49,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.
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ pub async fn start_tts(audio_sink: TtsOutStream) -> TtsControl {
|
||||
// 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();
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use crossterm::event::{self, KeyCode, KeyModifiers};
|
||||
use ratatui::{Frame, layout::{Direction, Layout, Rect}, style::{self, }, text::Span, widgets::{BorderType, Clear, 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::{PredictionAction, SessionControl, SessionUpdate}, scene::{Scene, conversation::ConversationEntry}, transcription::TranscriptionControl, tts::TtsControl};
|
||||
use crate::widgets::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Ui {
|
||||
scene: Scene,
|
||||
|
||||
reply_state: ListState,
|
||||
conversation_state: ListState,
|
||||
user_input: Input,
|
||||
throbber_state: ThrobberState,
|
||||
is_requesting: bool,
|
||||
audio_level: f64,
|
||||
recording_audio: bool,
|
||||
focus_state: FocusState,
|
||||
last_tick: Instant,
|
||||
|
||||
transcription: TranscriptionControl,
|
||||
audio: AudioInputControl,
|
||||
tts: TtsControl,
|
||||
predictions: SessionControl
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum FocusState {
|
||||
Conversation,
|
||||
UserInput
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
pub fn new(predictions: SessionControl, audio: AudioInputControl, transcription: TranscriptionControl, tts: TtsControl) -> Self {
|
||||
Self {
|
||||
scene: Default::default(),
|
||||
reply_state: Default::default(),
|
||||
conversation_state: Default::default(),
|
||||
user_input: Default::default(),
|
||||
throbber_state: Default::default(),
|
||||
is_requesting: false,
|
||||
audio_level: -60.,
|
||||
audio,
|
||||
recording_audio: false,
|
||||
transcription,
|
||||
focus_state: FocusState::UserInput,
|
||||
tts,
|
||||
predictions,
|
||||
last_tick: Instant::now()
|
||||
}
|
||||
}
|
||||
|
||||
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.scene.reply_options()), 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_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(4), Constraint::Fill(1)])
|
||||
.split(layout[0]);
|
||||
|
||||
frame.render_stateful_widget(Conversation(self.scene.conversation()), scene_layout[0], &mut self.conversation_state);
|
||||
self.draw_narration(frame, scene_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]);
|
||||
frame.render_widget(StatusBar(&self.scene), status_layout[1]);
|
||||
frame.render_widget(RecordingStatus(self.recording_audio), status_layout[2]);
|
||||
frame.render_widget(Volume(self.audio_level, self.recording_audio), status_layout[3]);
|
||||
}
|
||||
|
||||
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.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::StageDirection(direction.clone()))).await;
|
||||
}
|
||||
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::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]");
|
||||
return;
|
||||
}
|
||||
},
|
||||
"/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::ConversationAppend(ConversationEntry::ShipComputer(arg.to_string()))).await;
|
||||
},
|
||||
_ => {
|
||||
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) {
|
||||
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.predictions.regenerate_options().await,
|
||||
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if self.recording_audio {
|
||||
self.recording_audio = false;
|
||||
self.transcription.stop();
|
||||
} 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.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::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 => (),
|
||||
next_update = self.predictions.changed() => {
|
||||
match next_update {
|
||||
SessionUpdate::Thinking(is_thinking) => self.is_requesting = is_thinking,
|
||||
SessionUpdate::Scene(scene) => {
|
||||
self.scene = scene;
|
||||
self.reply_state.select_first();
|
||||
}
|
||||
}
|
||||
},
|
||||
next_volume = self.audio.next() => {
|
||||
self.audio_level = next_volume
|
||||
},
|
||||
transcription_result = self.transcription.next() => {
|
||||
self.predictions.insert(PredictionAction::ConversationAppend(ConversationEntry::User(transcription_result))).await;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
use chrono::Duration;
|
||||
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 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 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();
|
||||
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).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}", 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 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.scenery().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.0.scenery().artifacts.len())).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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user