Compare commits
76 Commits
feat/mkeve
...
main
Author | SHA1 | Date |
---|---|---|
Derek | 33fd5957ca | |
Derek | 711b1a46e9 | |
Derek | 00bc1ff061 | |
Derek | c03459c7ae | |
Derek | 2458a6a138 | |
Derek | d257d63cab | |
Derek | 0cbd64fbb7 | |
Derek | f8ba3ca698 | |
Derek | b6a7e4f6b5 | |
Derek | 41383b0885 | |
Derek | e7d16f44f4 | |
Derek | 2ab370d4a0 | |
Derek | ebf3e4d19b | |
Derek | ea5fef9321 | |
Derek | 3e5e7dd08a | |
Derek | c18e47aec2 | |
Derek | d21238282c | |
Derek | b7abd2941c | |
Derek | fe60c07f1a | |
Derek | d1d8611c68 | |
Derek | 91b8191c0e | |
Derek | a3a3e3a375 | |
Derek | bca46225a1 | |
Derek | 311e01bf56 | |
Derek | 0444a214e1 | |
Derek | 2662634723 | |
Derek | 36c2873532 | |
Derek | 272532b389 | |
Derek | bd45b8684b | |
Derek | 4efa4b25c9 | |
Derek | ee2267709c | |
Derek | 402227f731 | |
Derek | f6e4e24c51 | |
Derek | 4887f47647 | |
Derek | 8243ac4a3a | |
Derek | 198317a7e1 | |
Derek | 66de3e6d93 | |
Derek | f10ad13982 | |
Derek | cfd1422113 | |
Derek | b15d52011b | |
Derek | e0add05b2f | |
Derek | 7ae9f26930 | |
Derek | 65d724ae41 | |
Derek | 67c244b556 | |
Derek | 5d4ce64198 | |
Derek | 3ddde90420 | |
Derek | 318fd4858c | |
Derek | 500f0a0773 | |
Derek | b5a72d4f3d | |
Derek | 79b1158c3c | |
Derek | d014359e0a | |
Derek | 84c8f983d7 | |
Derek | ea20b946b4 | |
Derek | b9785d8533 | |
Derek | ce1e237d99 | |
Derek | ecb72a1fb2 | |
Derek | 1143111d7d | |
Derek | d8830f8834 | |
Derek | 1a39574cc8 | |
Derek | 9773afab10 | |
Derek | 8c9b45705e | |
Derek | c3b847bf78 | |
Derek | 9937951e69 | |
Derek | 0321899058 | |
Derek | f51595af62 | |
Derek | f06d16ba1f | |
Derek | 3a86b63621 | |
Derek | b9a1976b2c | |
Derek | 0cd2e4d9e8 | |
Derek | 4185032ca4 | |
Derek | a3182fc775 | |
Derek | a542ca63a6 | |
Derek | ca01a7574d | |
Derek | 1c02abe624 | |
Derek | e71b7c7d30 | |
Derek | 64286b2f2e |
|
@ -1,4 +1,7 @@
|
|||
.venv/
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
__pycache__/
|
||||
/dist/
|
||||
*.secret*
|
||||
secrets.kdl
|
||||
__pycache__/
|
||||
/*-log
|
||||
|
|
30
Pipfile
30
Pipfile
|
@ -1,30 +0,0 @@
|
|||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
requests = "*"
|
||||
websockets = "*"
|
||||
miniirc = "*"
|
||||
toml = "*"
|
||||
num2words = "*"
|
||||
pyaudio = { git="https://git.skeh.site/skeh/pyaudio.git" }
|
||||
soundfile = "*"
|
||||
numpy = "*"
|
||||
click = "*"
|
||||
owoify-py = "*"
|
||||
kdl-py = "*"
|
||||
maya = "*"
|
||||
multipledispatch = "*"
|
||||
blessed = "*"
|
||||
appdirs = "*"
|
||||
watchdog = "*"
|
||||
mido = "*"
|
||||
python-rtmidi = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.10"
|
||||
|
||||
[dev-packages]
|
||||
pipenv-setup = "*"
|
|
@ -1,771 +0,0 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "9cc59ed418d90ad6bc96bf7c011182f142b32b875d959149ebda8b6b748e0ca2"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.10"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"appdirs": {
|
||||
"hashes": [
|
||||
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
|
||||
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.4.4"
|
||||
},
|
||||
"blessed": {
|
||||
"hashes": [
|
||||
"sha256:63b8554ae2e0e7f43749b6715c734cc8f3883010a809bf16790102563e6cf25b",
|
||||
"sha256:9a0d099695bf621d4680dd6c73f6ad547f6a3442fbdbe80c4b1daa1edbc492fc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.19.1"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
|
||||
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
|
||||
],
|
||||
"version": "==2021.10.8"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3",
|
||||
"sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2",
|
||||
"sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636",
|
||||
"sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20",
|
||||
"sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728",
|
||||
"sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27",
|
||||
"sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66",
|
||||
"sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443",
|
||||
"sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0",
|
||||
"sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7",
|
||||
"sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39",
|
||||
"sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605",
|
||||
"sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a",
|
||||
"sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37",
|
||||
"sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029",
|
||||
"sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139",
|
||||
"sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc",
|
||||
"sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df",
|
||||
"sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14",
|
||||
"sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880",
|
||||
"sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2",
|
||||
"sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a",
|
||||
"sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e",
|
||||
"sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474",
|
||||
"sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024",
|
||||
"sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8",
|
||||
"sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0",
|
||||
"sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e",
|
||||
"sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a",
|
||||
"sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e",
|
||||
"sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032",
|
||||
"sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6",
|
||||
"sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e",
|
||||
"sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b",
|
||||
"sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e",
|
||||
"sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954",
|
||||
"sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962",
|
||||
"sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c",
|
||||
"sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4",
|
||||
"sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55",
|
||||
"sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962",
|
||||
"sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023",
|
||||
"sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c",
|
||||
"sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6",
|
||||
"sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8",
|
||||
"sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382",
|
||||
"sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7",
|
||||
"sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc",
|
||||
"sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997",
|
||||
"sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"
|
||||
],
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
|
||||
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
|
||||
],
|
||||
"markers": "python_version >= '3.0'",
|
||||
"version": "==2.0.12"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1",
|
||||
"sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==8.0.4"
|
||||
},
|
||||
"dateparser": {
|
||||
"hashes": [
|
||||
"sha256:038196b1f12c7397e38aad3d61588833257f6f552baa63a1499e6987fa8d42d9",
|
||||
"sha256:9600874312ff28a41f96ec7ccdc73be1d1c44435719da47fea3339d55ff5a628"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"docopt": {
|
||||
"hashes": [
|
||||
"sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"
|
||||
],
|
||||
"version": "==0.6.2"
|
||||
},
|
||||
"humanize": {
|
||||
"hashes": [
|
||||
"sha256:8d86333b8557dacffd4dce1dbe09c81c189e2caf7bb17a970b2212f0f58f10f2",
|
||||
"sha256:ee1f872fdfc7d2ef4a28d4f80ddde9f96d36955b5d6b0dac4bdeb99502bddb00"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||
],
|
||||
"markers": "python_version >= '3.0'",
|
||||
"version": "==3.3"
|
||||
},
|
||||
"kdl-py": {
|
||||
"hashes": [
|
||||
"sha256:9faf3ba7c4c5fde8cbc2ce337008377adfe6d71f029c26b5eff7175d8d570f82",
|
||||
"sha256:ab4a97a107dddc76dedc74ad402e58b56366280913de4c7da9d79e29308677fa"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"maya": {
|
||||
"hashes": [
|
||||
"sha256:7f53e06d5a123613dce7c270cbc647643a6942590dba7a19ec36194d0338c3f4",
|
||||
"sha256:fa90d8c6c9a730a7f740dec6e1c7d3da8ca10159e40bb843e4e72772f5e3a9a3"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"mido": {
|
||||
"hashes": [
|
||||
"sha256:0e618232063e0a220249da4961563c7636fea00096cfb3e2b87a4231f0ac1a9e",
|
||||
"sha256:17b38a8e4594497b850ec6e78b848eac3661706bfc49d484a36d91335a373499"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.2.10"
|
||||
},
|
||||
"miniirc": {
|
||||
"hashes": [
|
||||
"sha256:e27c50475624758636087e43e9ec1e3905c4fd5d9758023f1f6da24652fbca7a",
|
||||
"sha256:e94806e8df0625492c5d4261059e00b6a61a0bcf36111cc3bb55843eae74e337"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.8.0"
|
||||
},
|
||||
"multipledispatch": {
|
||||
"hashes": [
|
||||
"sha256:407e6d8c5fa27075968ba07c4db3ef5f02bea4e871e959570eeb69ee39a6565b",
|
||||
"sha256:a55c512128fb3f7c2efd2533f2550accb93c35f1045242ef74645fc92a2c3cba",
|
||||
"sha256:a7ab1451fd0bf9b92cab3edbd7b205622fb767aeefb4fb536c2e3de9e0a38bea"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.6.0"
|
||||
},
|
||||
"num2words": {
|
||||
"hashes": [
|
||||
"sha256:0b6e5f53f11d3005787e206d9c03382f459ef048a43c544e3db3b1e05a961548",
|
||||
"sha256:37cd4f60678f7e1045cdc3adf6acf93c8b41bf732da860f97d301f04e611cc57"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.5.10"
|
||||
},
|
||||
"numpy": {
|
||||
"hashes": [
|
||||
"sha256:07a8c89a04997625236c5ecb7afe35a02af3896c8aa01890a849913a2309c676",
|
||||
"sha256:08d9b008d0156c70dc392bb3ab3abb6e7a711383c3247b410b39962263576cd4",
|
||||
"sha256:201b4d0552831f7250a08d3b38de0d989d6f6e4658b709a02a73c524ccc6ffce",
|
||||
"sha256:2c10a93606e0b4b95c9b04b77dc349b398fdfbda382d2a39ba5a822f669a0123",
|
||||
"sha256:3ca688e1b9b95d80250bca34b11a05e389b1420d00e87a0d12dc45f131f704a1",
|
||||
"sha256:48a3aecd3b997bf452a2dedb11f4e79bc5bfd21a1d4cc760e703c31d57c84b3e",
|
||||
"sha256:568dfd16224abddafb1cbcce2ff14f522abe037268514dd7e42c6776a1c3f8e5",
|
||||
"sha256:5bfb1bb598e8229c2d5d48db1860bcf4311337864ea3efdbe1171fb0c5da515d",
|
||||
"sha256:639b54cdf6aa4f82fe37ebf70401bbb74b8508fddcf4797f9fe59615b8c5813a",
|
||||
"sha256:8251ed96f38b47b4295b1ae51631de7ffa8260b5b087808ef09a39a9d66c97ab",
|
||||
"sha256:92bfa69cfbdf7dfc3040978ad09a48091143cffb778ec3b03fa170c494118d75",
|
||||
"sha256:97098b95aa4e418529099c26558eeb8486e66bd1e53a6b606d684d0c3616b168",
|
||||
"sha256:a3bae1a2ed00e90b3ba5f7bd0a7c7999b55d609e0c54ceb2b076a25e345fa9f4",
|
||||
"sha256:c34ea7e9d13a70bf2ab64a2532fe149a9aced424cd05a2c4ba662fd989e3e45f",
|
||||
"sha256:dbc7601a3b7472d559dc7b933b18b4b66f9aa7452c120e87dfb33d02008c8a18",
|
||||
"sha256:e7927a589df200c5e23c57970bafbd0cd322459aa7b1ff73b7c2e84d6e3eae62",
|
||||
"sha256:f8c1f39caad2c896bc0018f699882b345b2a63708008be29b1f355ebf6f933fe",
|
||||
"sha256:f950f8845b480cffe522913d35567e29dd381b0dc7e4ce6a4a9f9156417d2430",
|
||||
"sha256:fade0d4f4d292b6f39951b6836d7a3c7ef5b2347f3c420cd9820a1d90d794802",
|
||||
"sha256:fdf3c08bce27132395d3c3ba1503cac12e17282358cb4bddc25cc46b0aca07aa"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.22.3"
|
||||
},
|
||||
"owoify-py": {
|
||||
"hashes": [
|
||||
"sha256:059df47cebef4f76107ddbcb98f5ea96fe8988bc71d4595fb77798a1303921d3",
|
||||
"sha256:3e3e45468256ffc590fbedf4833624633bab81f5578e60c02e6b8a25889951a4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.2"
|
||||
},
|
||||
"pendulum": {
|
||||
"hashes": [
|
||||
"sha256:0731f0c661a3cb779d398803655494893c9f581f6488048b3fb629c2342b5394",
|
||||
"sha256:1245cd0075a3c6d889f581f6325dd8404aca5884dea7223a5566c38aab94642b",
|
||||
"sha256:29c40a6f2942376185728c9a0347d7c0f07905638c83007e1d262781f1e6953a",
|
||||
"sha256:2d1619a721df661e506eff8db8614016f0720ac171fe80dda1333ee44e684087",
|
||||
"sha256:318f72f62e8e23cd6660dbafe1e346950281a9aed144b5c596b2ddabc1d19739",
|
||||
"sha256:33fb61601083f3eb1d15edeb45274f73c63b3c44a8524703dc143f4212bf3269",
|
||||
"sha256:3481fad1dc3f6f6738bd575a951d3c15d4b4ce7c82dce37cf8ac1483fde6e8b0",
|
||||
"sha256:4c9c689747f39d0d02a9f94fcee737b34a5773803a64a5fdb046ee9cac7442c5",
|
||||
"sha256:7c5ec650cb4bec4c63a89a0242cc8c3cebcec92fcfe937c417ba18277d8560be",
|
||||
"sha256:94b1fc947bfe38579b28e1cccb36f7e28a15e841f30384b5ad6c5e31055c85d7",
|
||||
"sha256:9702069c694306297ed362ce7e3c1ef8404ac8ede39f9b28b7c1a7ad8c3959e3",
|
||||
"sha256:b06a0ca1bfe41c990bbf0c029f0b6501a7f2ec4e38bfec730712015e8860f207",
|
||||
"sha256:b6c352f4bd32dff1ea7066bd31ad0f71f8d8100b9ff709fb343f3b86cee43efe",
|
||||
"sha256:c501749fdd3d6f9e726086bf0cd4437281ed47e7bca132ddb522f86a1645d360",
|
||||
"sha256:c807a578a532eeb226150d5006f156632df2cc8c5693d778324b43ff8c515dd0",
|
||||
"sha256:db0a40d8bcd27b4fb46676e8eb3c732c67a5a5e6bfab8927028224fbced0b40b",
|
||||
"sha256:de42ea3e2943171a9e95141f2eecf972480636e8e484ccffaf1e833929e9e052",
|
||||
"sha256:e95d329384717c7bf627bf27e204bc3b15c8238fa8d9d9781d93712776c14002",
|
||||
"sha256:f5e236e7730cab1644e1b87aca3d2ff3e375a608542e90fe25685dae46310116",
|
||||
"sha256:f888f2d2909a414680a29ae74d0592758f2b9fcdee3549887779cd4055e975db",
|
||||
"sha256:fb53ffa0085002ddd43b6ca61a7b34f2d4d7c3ed66f931fe599e1a531b42af9b"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.1.2"
|
||||
},
|
||||
"pyaudio": {
|
||||
"git": "https://git.skeh.site/skeh/pyaudio.git",
|
||||
"ref": "f749f2187e232217f8ac112a1226b3af11f008e3"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9",
|
||||
"sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"
|
||||
],
|
||||
"version": "==2.21"
|
||||
},
|
||||
"python-dateutil": {
|
||||
"hashes": [
|
||||
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
|
||||
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.8.2"
|
||||
},
|
||||
"python-rtmidi": {
|
||||
"hashes": [
|
||||
"sha256:04bd95dd86fef3fe8d72838f719d31875f107a842127024bfe276617961eed5d",
|
||||
"sha256:0f5409e1b2e92cfe377710a0ea5c450c58fda8b52ec4bf4baf517aa731d9f6a6",
|
||||
"sha256:2286ab096a5603430ab1e1a664fe4d96acc40f9443f44c27e911dfad85ea3ac8",
|
||||
"sha256:3d4b7fdb477d8036d51cce281b303686114ae676f1c83693db963ab01db11cf5",
|
||||
"sha256:4d75788163327f6ac1f898c29e3b4527da83dbc5bab5a7e614b6a4385fde3231",
|
||||
"sha256:54d40cb794a6b079105bfb923ab0b4d5f041e9eef5dc5cce25480e4c3e3fc2f6",
|
||||
"sha256:5dad7a28035eea9e24aaab4fcb756cd2b78c5b14835aa64750cb4062f77ec169",
|
||||
"sha256:69907663e0f167fcf3fc1632a792419d0d9d00550f94dca39370ebda3bc999db",
|
||||
"sha256:6f495672ec76700400d4dff6f8848dbd52ca60301ed6011a6a1b3a9e95a7a07e",
|
||||
"sha256:7d27d0a70e85d991f1451f286416cf5ef4514292b027155bf91dcae0b8c0d5d2",
|
||||
"sha256:8f7e154681c5d6ed7228ce8639708592da758ef4c0f575f7020854e07ca6478b",
|
||||
"sha256:bfeb4ed99d0cccf6fa2837566907652ded7adc1c03b69f2160c9de4082301302",
|
||||
"sha256:d201516bb1c64511e7e4de50533f6b828072113e3c26f3f5b657f11b90252073"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.4.9"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7",
|
||||
"sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"
|
||||
],
|
||||
"version": "==2022.1"
|
||||
},
|
||||
"pytz-deprecation-shim": {
|
||||
"hashes": [
|
||||
"sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6",
|
||||
"sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
|
||||
"version": "==0.1.0.post0"
|
||||
},
|
||||
"pytzdata": {
|
||||
"hashes": [
|
||||
"sha256:3efa13b335a00a8de1d345ae41ec78dd11c9f8807f522d39850f2dd828681540",
|
||||
"sha256:e1e14750bcf95016381e4d472bad004eef710f2d6417240904070b3d6654485f"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2020.1"
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:0008650041531d0eadecc96a73d37c2dc4821cf51b0766e374cb4f1ddc4e1c14",
|
||||
"sha256:03299b0bcaa7824eb7c0ebd7ef1e3663302d1b533653bfe9dc7e595d453e2ae9",
|
||||
"sha256:06b1df01cf2aef3a9790858af524ae2588762c8a90e784ba00d003f045306204",
|
||||
"sha256:09b4b6ccc61d4119342b26246ddd5a04accdeebe36bdfe865ad87a0784efd77f",
|
||||
"sha256:0be0c34a39e5d04a62fd5342f0886d0e57592a4f4993b3f9d257c1f688b19737",
|
||||
"sha256:0d96eec8550fd2fd26f8e675f6d8b61b159482ad8ffa26991b894ed5ee19038b",
|
||||
"sha256:0eb0e2845e81bdea92b8281a3969632686502565abf4a0b9e4ab1471c863d8f3",
|
||||
"sha256:13bbf0c9453c6d16e5867bda7f6c0c7cff1decf96c5498318bb87f8136d2abd4",
|
||||
"sha256:17e51ad1e6131c496b58d317bc9abec71f44eb1957d32629d06013a21bc99cac",
|
||||
"sha256:1977bb64264815d3ef016625adc9df90e6d0e27e76260280c63eca993e3f455f",
|
||||
"sha256:1e30762ddddb22f7f14c4f59c34d3addabc789216d813b0f3e2788d7bcf0cf29",
|
||||
"sha256:1e73652057473ad3e6934944af090852a02590c349357b79182c1b681da2c772",
|
||||
"sha256:20e6a27959f162f979165e496add0d7d56d7038237092d1aba20b46de79158f1",
|
||||
"sha256:286ff9ec2709d56ae7517040be0d6c502642517ce9937ab6d89b1e7d0904f863",
|
||||
"sha256:297c42ede2c81f0cb6f34ea60b5cf6dc965d97fa6936c11fc3286019231f0d66",
|
||||
"sha256:320c2f4106962ecea0f33d8d31b985d3c185757c49c1fb735501515f963715ed",
|
||||
"sha256:35ed2f3c918a00b109157428abfc4e8d1ffabc37c8f9abc5939ebd1e95dabc47",
|
||||
"sha256:3d146e5591cb67c5e836229a04723a30af795ef9b70a0bbd913572e14b7b940f",
|
||||
"sha256:42bb37e2b2d25d958c25903f6125a41aaaa1ed49ca62c103331f24b8a459142f",
|
||||
"sha256:42d6007722d46bd2c95cce700181570b56edc0dcbadbfe7855ec26c3f2d7e008",
|
||||
"sha256:43eba5c46208deedec833663201752e865feddc840433285fbadee07b84b464d",
|
||||
"sha256:452519bc4c973e961b1620c815ea6dd8944a12d68e71002be5a7aff0a8361571",
|
||||
"sha256:4b9c16a807b17b17c4fa3a1d8c242467237be67ba92ad24ff51425329e7ae3d0",
|
||||
"sha256:5510932596a0f33399b7fff1bd61c59c977f2b8ee987b36539ba97eb3513584a",
|
||||
"sha256:55820bc631684172b9b56a991d217ec7c2e580d956591dc2144985113980f5a3",
|
||||
"sha256:57484d39447f94967e83e56db1b1108c68918c44ab519b8ecfc34b790ca52bf7",
|
||||
"sha256:58ba41e462653eaf68fc4a84ec4d350b26a98d030be1ab24aba1adcc78ffe447",
|
||||
"sha256:5bc5f921be39ccb65fdda741e04b2555917a4bced24b4df14eddc7569be3b493",
|
||||
"sha256:5dcc4168536c8f68654f014a3db49b6b4a26b226f735708be2054314ed4964f4",
|
||||
"sha256:5f92a7cdc6a0ae2abd184e8dfd6ef2279989d24c85d2c85d0423206284103ede",
|
||||
"sha256:67250b36edfa714ba62dc62d3f238e86db1065fccb538278804790f578253640",
|
||||
"sha256:6df070a986fc064d865c381aecf0aaff914178fdf6874da2f2387e82d93cc5bd",
|
||||
"sha256:729aa8ca624c42f309397c5fc9e21db90bf7e2fdd872461aabdbada33de9063c",
|
||||
"sha256:72bc3a5effa5974be6d965ed8301ac1e869bc18425c8a8fac179fbe7876e3aee",
|
||||
"sha256:74d86e8924835f863c34e646392ef39039405f6ce52956d8af16497af4064a30",
|
||||
"sha256:79e5af1ff258bc0fe0bdd6f69bc4ae33935a898e3cbefbbccf22e88a27fa053b",
|
||||
"sha256:7b103dffb9f6a47ed7ffdf352b78cfe058b1777617371226c1894e1be443afec",
|
||||
"sha256:83f03f0bd88c12e63ca2d024adeee75234d69808b341e88343b0232329e1f1a1",
|
||||
"sha256:86d7a68fa53688e1f612c3246044157117403c7ce19ebab7d02daf45bd63913e",
|
||||
"sha256:878c626cbca3b649e14e972c14539a01191d79e58934e3f3ef4a9e17f90277f8",
|
||||
"sha256:878f5d649ba1db9f52cc4ef491f7dba2d061cdc48dd444c54260eebc0b1729b9",
|
||||
"sha256:87bc01226cd288f0bd9a4f9f07bf6827134dc97a96c22e2d28628e824c8de231",
|
||||
"sha256:8babb2b5751105dc0aef2a2e539f4ba391e738c62038d8cb331c710f6b0f3da7",
|
||||
"sha256:91e0f7e7be77250b808a5f46d90bf0032527d3c032b2131b63dee54753a4d729",
|
||||
"sha256:9557545c10d52c845f270b665b52a6a972884725aa5cf12777374e18f2ea8960",
|
||||
"sha256:9ccb0a4ab926016867260c24c192d9df9586e834f5db83dfa2c8fffb3a6e5056",
|
||||
"sha256:9d828c5987d543d052b53c579a01a52d96b86f937b1777bbfe11ef2728929357",
|
||||
"sha256:9efa41d1527b366c88f265a227b20bcec65bda879962e3fc8a2aee11e81266d7",
|
||||
"sha256:aaf5317c961d93c1a200b9370fb1c6b6836cc7144fef3e5a951326912bf1f5a3",
|
||||
"sha256:ab69b4fe09e296261377d209068d52402fb85ef89dc78a9ac4a29a895f4e24a7",
|
||||
"sha256:ad397bc7d51d69cb07ef89e44243f971a04ce1dca9bf24c992c362406c0c6573",
|
||||
"sha256:ae17fc8103f3b63345709d3e9654a274eee1c6072592aec32b026efd401931d0",
|
||||
"sha256:af4d8cc28e4c7a2f6a9fed544228c567340f8258b6d7ea815b62a72817bbd178",
|
||||
"sha256:b22ff939a8856a44f4822da38ef4868bd3a9ade22bb6d9062b36957c850e404f",
|
||||
"sha256:b549d851f91a4efb3e65498bd4249b1447ab6035a9972f7fc215eb1f59328834",
|
||||
"sha256:be319f4eb400ee567b722e9ea63d5b2bb31464e3cf1b016502e3ee2de4f86f5c",
|
||||
"sha256:c0446b2871335d5a5e9fcf1462f954586b09a845832263db95059dcd01442015",
|
||||
"sha256:c68d2c04f7701a418ec2e5631b7f3552efc32f6bcc1739369c6eeb1af55f62e0",
|
||||
"sha256:c87ac58b9baaf50b6c1b81a18d20eda7e2883aa9a4fb4f1ca70f2e443bfcdc57",
|
||||
"sha256:caa2734ada16a44ae57b229d45091f06e30a9a52ace76d7574546ab23008c635",
|
||||
"sha256:cb34c2d66355fb70ae47b5595aafd7218e59bb9c00ad8cc3abd1406ca5874f07",
|
||||
"sha256:cb3652bbe6720786b9137862205986f3ae54a09dec8499a995ed58292bdf77c2",
|
||||
"sha256:cf668f26604e9f7aee9f8eaae4ca07a948168af90b96be97a4b7fa902a6d2ac1",
|
||||
"sha256:d326ff80ed531bf2507cba93011c30fff2dd51454c85f55df0f59f2030b1687b",
|
||||
"sha256:d6c2441538e4fadd4291c8420853431a229fcbefc1bf521810fbc2629d8ae8c2",
|
||||
"sha256:d6ecfd1970b3380a569d7b3ecc5dd70dba295897418ed9e31ec3c16a5ab099a5",
|
||||
"sha256:e5602a9b5074dcacc113bba4d2f011d2748f50e3201c8139ac5b68cf2a76bd8b",
|
||||
"sha256:ef806f684f17dbd6263d72a54ad4073af42b42effa3eb42b877e750c24c76f86",
|
||||
"sha256:f3356afbb301ec34a500b8ba8b47cba0b44ed4641c306e1dd981a08b416170b5",
|
||||
"sha256:f6f7ee2289176cb1d2c59a24f50900f8b9580259fa9f1a739432242e7d254f93",
|
||||
"sha256:f7e8f1ee28e0a05831c92dc1c0c1c94af5289963b7cf09eca5b5e3ce4f8c91b0",
|
||||
"sha256:f8169ec628880bdbca67082a9196e2106060a4a5cbd486ac51881a4df805a36f",
|
||||
"sha256:fbc88d3ba402b5d041d204ec2449c4078898f89c4a6e6f0ed1c1a510ef1e221d",
|
||||
"sha256:fbd3fe37353c62fd0eb19fb76f78aa693716262bcd5f9c14bb9e5aca4b3f0dc4"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2022.3.2"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
|
||||
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.27.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
|
||||
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.16.0"
|
||||
},
|
||||
"snaptime": {
|
||||
"hashes": [
|
||||
"sha256:e3f1eb89043d58d30721ab98cb65023f1a4c2740e3b197704298b163c92d508b"
|
||||
],
|
||||
"version": "==0.2.4"
|
||||
},
|
||||
"soundfile": {
|
||||
"hashes": [
|
||||
"sha256:2d17e0a6fc2af0d6c1d868bafa5ec80aae6e186a97fec8db07ad6af29842fbc7",
|
||||
"sha256:4555438c2c4f02b39fea2ed40f6ddeda88a80cd1ee9dd129be4d5f5134698cc2",
|
||||
"sha256:490cff42650733d1832728b937fe99fa1802896f5ef4d61bcf78cf7ebecb107b",
|
||||
"sha256:5e342ee293b896d31da67617fe65d0bdca217af193991b0cb6052353b1e0e506",
|
||||
"sha256:b361d4ac1519a2e516cabafa6bf7e93492f999f35d7d25350cd87fdc3e5cb27e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.10.3.post1"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
|
||||
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"tzdata": {
|
||||
"hashes": [
|
||||
"sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9",
|
||||
"sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2022.1"
|
||||
},
|
||||
"tzlocal": {
|
||||
"hashes": [
|
||||
"sha256:0f28015ac68a5c067210400a9197fc5d36ba9bc3f8eaf1da3cbd59acdfed9e09",
|
||||
"sha256:28ba8d9fcb6c9a782d6e0078b4f6627af1ea26aeaa32b4eab5324abc7df4149f"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==4.1"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14",
|
||||
"sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==1.26.9"
|
||||
},
|
||||
"watchdog": {
|
||||
"hashes": [
|
||||
"sha256:03b43d583df0f18782a0431b6e9e9965c5b3f7cf8ec36a00b930def67942c385",
|
||||
"sha256:0908bb50f6f7de54d5d31ec3da1654cb7287c6b87bce371954561e6de379d690",
|
||||
"sha256:0b4a1fe6201c6e5a1926f5767b8664b45f0fcb429b62564a41f490ff1ce1dc7a",
|
||||
"sha256:177bae28ca723bc00846466016d34f8c1d6a621383b6caca86745918d55c7383",
|
||||
"sha256:19b36d436578eb437e029c6b838e732ed08054956366f6dd11875434a62d2b99",
|
||||
"sha256:1d1cf7dfd747dec519486a98ef16097e6c480934ef115b16f18adb341df747a4",
|
||||
"sha256:1e877c70245424b06c41ac258023ea4bd0c8e4ff15d7c1368f17cd0ae6e351dd",
|
||||
"sha256:340b875aecf4b0e6672076a6f05cfce6686935559bb6d34cebedee04126a9566",
|
||||
"sha256:351e09b6d9374d5bcb947e6ac47a608ec25b9d70583e9db00b2fcdb97b00b572",
|
||||
"sha256:3fd47815353be9c44eebc94cc28fe26b2b0c5bd889dafc4a5a7cbdf924143480",
|
||||
"sha256:49639865e3db4be032a96695c98ac09eed39bbb43fe876bb217da8f8101689a6",
|
||||
"sha256:4d0e98ac2e8dd803a56f4e10438b33a2d40390a72750cff4939b4b274e7906fa",
|
||||
"sha256:6e6ae29b72977f2e1ee3d0b760d7ee47896cb53e831cbeede3e64485e5633cc8",
|
||||
"sha256:7f14ce6adea2af1bba495acdde0e510aecaeb13b33f7bd2f6324e551b26688ca",
|
||||
"sha256:81982c7884aac75017a6ecc72f1a4fedbae04181a8665a34afce9539fc1b3fab",
|
||||
"sha256:81a5861d0158a7e55fe149335fb2bbfa6f48cbcbd149b52dbe2cd9a544034bbd",
|
||||
"sha256:ae934e34c11aa8296c18f70bf66ed60e9870fcdb4cc19129a04ca83ab23e7055",
|
||||
"sha256:b26e13e8008dcaea6a909e91d39b629a39635d1a8a7239dd35327c74f4388601",
|
||||
"sha256:b3750ee5399e6e9c69eae8b125092b871ee9e2fcbd657a92747aea28f9056a5c",
|
||||
"sha256:b61acffaf5cd5d664af555c0850f9747cc5f2baf71e54bbac164c58398d6ca7b",
|
||||
"sha256:b9777664848160449e5b4260e0b7bc1ae0f6f4992a8b285db4ec1ef119ffa0e2",
|
||||
"sha256:bdcbf75580bf4b960fb659bbccd00123d83119619195f42d721e002c1621602f",
|
||||
"sha256:d802d65262a560278cf1a65ef7cae4e2bc7ecfe19e5451349e4c67e23c9dc420",
|
||||
"sha256:ed6d9aad09a2a948572224663ab00f8975fae242aa540509737bb4507133fa2d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.1.7"
|
||||
},
|
||||
"wcwidth": {
|
||||
"hashes": [
|
||||
"sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784",
|
||||
"sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"
|
||||
],
|
||||
"version": "==0.2.5"
|
||||
},
|
||||
"websockets": {
|
||||
"hashes": [
|
||||
"sha256:038afef2a05893578d10dadbdbb5f112bd115c46347e1efe99f6a356ff062138",
|
||||
"sha256:05f6e9757017270e7a92a2975e2ae88a9a582ffc4629086fd6039aa80e99cd86",
|
||||
"sha256:0b66421f9f13d4df60cd48ab977ed2c2b6c9147ae1a33caf5a9f46294422fda1",
|
||||
"sha256:0cd02f36d37e503aca88ab23cc0a1a0e92a263d37acf6331521eb38040dcf77b",
|
||||
"sha256:0f73cb2526d6da268e86977b2c4b58f2195994e53070fe567d5487c6436047e6",
|
||||
"sha256:117383d0a17a0dda349f7a8790763dde75c1508ff8e4d6e8328b898b7df48397",
|
||||
"sha256:1c1f3b18c8162e3b09761d0c6a0305fd642934202541cc511ef972cb9463261e",
|
||||
"sha256:1c9031e90ebfc486e9cdad532b94004ade3aa39a31d3c46c105bb0b579cd2490",
|
||||
"sha256:2349fa81b6b959484bb2bda556ccb9eb70ba68987646a0f8a537a1a18319fb03",
|
||||
"sha256:24b879ba7db12bb525d4e58089fcbe6a3df3ce4666523183654170e86d372cbe",
|
||||
"sha256:2aa9b91347ecd0412683f28aabe27f6bad502d89bd363b76e0a3508b1596402e",
|
||||
"sha256:56d48eebe9e39ce0d68701bce3b21df923aa05dcc00f9fd8300de1df31a7c07c",
|
||||
"sha256:5a38a0175ae82e4a8c4bac29fc01b9ee26d7d5a614e5ee11e7813c68a7d938ce",
|
||||
"sha256:5b04270b5613f245ec84bb2c6a482a9d009aefad37c0575f6cda8499125d5d5c",
|
||||
"sha256:6193bbc1ee63aadeb9a4d81de0e19477401d150d506aee772d8380943f118186",
|
||||
"sha256:669e54228a4d9457abafed27cbf0e2b9f401445c4dfefc12bf8e4db9751703b8",
|
||||
"sha256:6a009eb551c46fd79737791c0c833fc0e5b56bcd1c3057498b262d660b92e9cd",
|
||||
"sha256:71a4491cfe7a9f18ee57d41163cb6a8a3fa591e0f0564ca8b0ed86b2a30cced4",
|
||||
"sha256:7b38a5c9112e3dbbe45540f7b60c5204f49b3cb501b40950d6ab34cd202ab1d0",
|
||||
"sha256:7bb9d8a6beca478c7e9bdde0159bd810cc1006ad6a7cb460533bae39da692ca2",
|
||||
"sha256:82bc33db6d8309dc27a3bee11f7da2288ad925fcbabc2a4bb78f7e9c56249baf",
|
||||
"sha256:8351c3c86b08156337b0e4ece0e3c5ec3e01fcd14e8950996832a23c99416098",
|
||||
"sha256:8beac786a388bb99a66c3be4ab0fb38273c0e3bc17f612a4e0a47c4fc8b9c045",
|
||||
"sha256:97950c7c844ec6f8d292440953ae18b99e3a6a09885e09d20d5e7ecd9b914cf8",
|
||||
"sha256:98f57b3120f8331cd7440dbe0e776474f5e3632fdaa474af1f6b754955a47d71",
|
||||
"sha256:9ca2ca05a4c29179f06cf6727b45dba5d228da62623ec9df4184413d8aae6cb9",
|
||||
"sha256:a03a25d95cc7400bd4d61a63460b5d85a7761c12075ee2f51de1ffe73aa593d3",
|
||||
"sha256:a10c0c1ee02164246f90053273a42d72a3b2452a7e7486fdae781138cf7fbe2d",
|
||||
"sha256:a72b92f96e5e540d5dda99ee3346e199ade8df63152fa3c737260da1730c411f",
|
||||
"sha256:ac081aa0307f263d63c5ff0727935c736c8dad51ddf2dc9f5d0c4759842aefaa",
|
||||
"sha256:b22bdc795e62e71118b63e14a08bacfa4f262fd2877de7e5b950f5ac16b0348f",
|
||||
"sha256:b4059e2ccbe6587b6dc9a01db5fc49ead9a884faa4076eea96c5ec62cb32f42a",
|
||||
"sha256:b7fe45ae43ac814beb8ca09d6995b56800676f2cfa8e23f42839dc69bba34a42",
|
||||
"sha256:bef03a51f9657fb03d8da6ccd233fe96e04101a852f0ffd35f5b725b28221ff3",
|
||||
"sha256:bffc65442dd35c473ca9790a3fa3ba06396102a950794f536783f4b8060af8dd",
|
||||
"sha256:c21a67ab9a94bd53e10bba21912556027fea944648a09e6508415ad14e37c325",
|
||||
"sha256:c67d9cacb3f6537ca21e9b224d4fd08481538e43bcac08b3d93181b0816def39",
|
||||
"sha256:c6e56606842bb24e16e36ae7eb308d866b4249cf0be8f63b212f287eeb76b124",
|
||||
"sha256:cb316b87cbe3c0791c2ad92a5a36bf6adc87c457654335810b25048c1daa6fd5",
|
||||
"sha256:cef40a1b183dcf39d23b392e9dd1d9b07ab9c46aadf294fff1350fb79146e72b",
|
||||
"sha256:cf931c33db9c87c53d009856045dd524e4a378445693382a920fa1e0eb77c36c",
|
||||
"sha256:d4d110a84b63c5cfdd22485acc97b8b919aefeecd6300c0c9d551e055b9a88ea",
|
||||
"sha256:d5396710f86a306cf52f87fd8ea594a0e894ba0cc5a36059eaca3a477dc332aa",
|
||||
"sha256:f09f46b1ff6d09b01c7816c50bd1903cf7d02ebbdb63726132717c2fcda835d5",
|
||||
"sha256:f14bd10e170abc01682a9f8b28b16e6f20acf6175945ef38db6ffe31b0c72c3f",
|
||||
"sha256:f5c335dc0e7dc271ef36df3f439868b3c790775f345338c2f61a562f1074187b",
|
||||
"sha256:f8296b8408ec6853b26771599990721a26403e62b9de7e50ac0a056772ac0b5e",
|
||||
"sha256:fa35c5d1830d0fb7b810324e9eeab9aa92e8f273f11fdbdc0741dcded6d72b9f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==10.2"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4",
|
||||
"sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==21.4.0"
|
||||
},
|
||||
"cached-property": {
|
||||
"hashes": [
|
||||
"sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130",
|
||||
"sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"
|
||||
],
|
||||
"version": "==1.5.2"
|
||||
},
|
||||
"cerberus": {
|
||||
"hashes": [
|
||||
"sha256:d1b21b3954b2498d9a79edf16b3170a3ac1021df88d197dc2ce5928ba519237c"
|
||||
],
|
||||
"version": "==1.3.4"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
|
||||
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
|
||||
],
|
||||
"version": "==2021.10.8"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
|
||||
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
|
||||
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
|
||||
],
|
||||
"markers": "python_version >= '3.0'",
|
||||
"version": "==2.0.12"
|
||||
},
|
||||
"colorama": {
|
||||
"hashes": [
|
||||
"sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
|
||||
"sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==0.4.4"
|
||||
},
|
||||
"distlib": {
|
||||
"hashes": [
|
||||
"sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b",
|
||||
"sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"
|
||||
],
|
||||
"version": "==0.3.4"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
|
||||
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
|
||||
],
|
||||
"markers": "python_version >= '3.0'",
|
||||
"version": "==3.3"
|
||||
},
|
||||
"orderedmultidict": {
|
||||
"hashes": [
|
||||
"sha256:04070bbb5e87291cc9bfa51df413677faf2141c73c61d2a5f7b26bea3cd882ad",
|
||||
"sha256:43c839a17ee3cdd62234c47deca1a8508a3f2ca1d0678a3bf791c87cf84adbf3"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5",
|
||||
"sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.9"
|
||||
},
|
||||
"pep517": {
|
||||
"hashes": [
|
||||
"sha256:931378d93d11b298cf511dd634cf5ea4cb249a28ef84160b3247ee9afb4e8ab0",
|
||||
"sha256:dd884c326898e2c6e11f9e0b64940606a93eb10ea022a2e067959f3a110cf161"
|
||||
],
|
||||
"version": "==0.12.0"
|
||||
},
|
||||
"pip-shims": {
|
||||
"hashes": [
|
||||
"sha256:b6704551be47f9a7b05cbdb51a180b6d68be4c980c255e22d6a6dec7feb8f087",
|
||||
"sha256:c0818a7a9bb49c1184324b2114432c3270ca31d4c5c3b0156ce8bf1826fc155b"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.6.0"
|
||||
},
|
||||
"pipenv-setup": {
|
||||
"hashes": [
|
||||
"sha256:0def7ec3363f58b38a43dc59b2078fcee67b47301fd51a41b8e34e6f79812b1a",
|
||||
"sha256:6ceda7145a3088494d8ca68fded4b0473022dc62eb786a021c137632c44298b5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.2.0"
|
||||
},
|
||||
"pipfile": {
|
||||
"hashes": [
|
||||
"sha256:f7d9f15de8b660986557eb3cc5391aa1a16207ac41bc378d03f414762d36c984"
|
||||
],
|
||||
"version": "==0.0.2"
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d",
|
||||
"sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2.5.1"
|
||||
},
|
||||
"plette": {
|
||||
"extras": [
|
||||
"validation"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:46402c03e36d6eadddad2a5125990e322dd74f98160c8f2dcd832b2291858a26",
|
||||
"sha256:d6c9b96981b347bddd333910b753b6091a2c1eb2ef85bb373b4a67c9d91dca16"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.2.3"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea",
|
||||
"sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.0.7"
|
||||
},
|
||||
"python-dateutil": {
|
||||
"hashes": [
|
||||
"sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86",
|
||||
"sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.8.2"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
|
||||
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.27.1"
|
||||
},
|
||||
"requirementslib": {
|
||||
"hashes": [
|
||||
"sha256:7986c9797df08e68f6dfbb6c6e948b1e108363ef70da82cb21fe219a965b2859",
|
||||
"sha256:b7d62aaa5177b85ba3cfa0ef6d0ebdf405787dd0660f38b2b6401f7c32e6529c"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==1.6.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
|
||||
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.16.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
|
||||
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"tomli": {
|
||||
"hashes": [
|
||||
"sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc",
|
||||
"sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2.0.1"
|
||||
},
|
||||
"tomlkit": {
|
||||
"hashes": [
|
||||
"sha256:cac4aeaff42f18fef6e07831c2c2689a51df76cf2ede07a6a4fa5fcb83558870",
|
||||
"sha256:d99946c6aed3387c98b89d91fb9edff8f901bf9255901081266a84fb5604adcd"
|
||||
],
|
||||
"markers": "python_version >= '3.6' and python_version < '4'",
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14",
|
||||
"sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==1.26.9"
|
||||
},
|
||||
"vistir": {
|
||||
"hashes": [
|
||||
"sha256:a37079cdbd85d31a41cdd18457fe521e15ec08b255811e81aa061fd5f48a20fb",
|
||||
"sha256:eff1d19ef50c703a329ed294e5ec0b0fbb35b96c1b3ee6dcdb266dddbe1e935a"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.5.2"
|
||||
},
|
||||
"wheel": {
|
||||
"hashes": [
|
||||
"sha256:4bdcd7d840138086126cd09254dc6195fb4fc6f01c050a1d7236f2630db1d22a",
|
||||
"sha256:e9a504e793efbca1b8e0e9cb979a249cf4a0a7b5b8c9e8b65a5e39d49529c1c4"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==0.37.1"
|
||||
}
|
||||
}
|
||||
}
|
89
README.md
89
README.md
|
@ -1,41 +1,84 @@
|
|||
# Open Vtuber Tool Kit - Audience Interaction module
|
||||
This is the project that handles audience interaction within OVTK, but is designed to be very useful standalone and in co-ordination with non-OVTK tools.
|
||||
### Notice
|
||||
Although super cool software, this is *beta* super cool software. Please be sure to test it before trusting it with mission-critical tasks, and (though it will be avoided as much as possible) configs / custom plugins may break between versions.
|
||||
|
||||
It consists of three parts:
|
||||
1. Chat modules
|
||||
2. A websocket server
|
||||
3. A powerful, easy to use plugin system
|
||||
# About audiencekit
|
||||
`audiencekit` is a streamer-centric automation tool and bot platform:
|
||||
+ Pulls events (chat messages, donations, etc) from various livestreaming platforms, normalizing them into generalized types
|
||||
+ Provides a rich system for reacting to (or mutating) those events
|
||||
+ Exposes the end-result as a simple websocket event steam
|
||||
|
||||
Part of (but usable independent from) the [Open Vtuber ToolKit][ovtk]
|
||||
|
||||
## Chat Modules
|
||||
As OVTK is designed to be platform-agnostic, both in terms of the host OS and the streaming platform, the job of the chat modules is to fetch and normalize audience events (chats, donations, etc).
|
||||
## For (Power) Users
|
||||
As you would hope, this system comes with batteries included, allowing you play sounds, do text-to-speech, control OBS, and much more, right out of the box. A love-it-or-hate-it decision, audiencekit assumes *nothing* about *how* you want these done, and instead lets **you** automate it all via plain-text config files.
|
||||
|
||||
Each runs in its own process, and so can be written with little concern for the rest of the application to ease development requirements.
|
||||
That might seem scary at first, but if you've ever edited an ini file or messed with html, you'll be glad to know this just as (if not more) simple:
|
||||
+ The syntax, [KDL][kdl], is inspired by the best of XML and command lines. Cuddly, just like you~
|
||||
+ The config is ran when the application starts (mostly) from top to bottom, and can be split up into multiple files.
|
||||
+ Things can be placed inside other things (like `trigger`), usually to make them happen later (rather than at application start), and sometimes to configure advanced properties.
|
||||
|
||||
## Websocket Server
|
||||
The websocket server publishes all events to any external clients that may be interested - ex, other OVTK software.
|
||||
|
||||
## Plugin system
|
||||
As this system sits directly between the streamer and the streaming service (which sits between the audience), it is a very handy place to be able to place custom logic or bots!
|
||||
|
||||
The system is highly capable out-of-the-box, allowing you to write simple automation via a straightforward config syntax called [KDL](https://kdl.dev). For example, a bot that replies to raid events would look like:
|
||||
For example, a simple raid automation looks like:
|
||||
```kdl
|
||||
trigger event="Raid" {
|
||||
reply (arg)"Thank you very much for the {event.user_count} raid, {event.from_channel}!"
|
||||
reply "Thank you very much for the raid!"
|
||||
}
|
||||
```
|
||||
|
||||
And writing your own plugin to extend the abilities available to you (or to implement complex logic) is just as straightforward. Here's an example of a plugin that automatically changes "simp" to "shrimp" in every message (as seen by those consuming the websocket output):
|
||||
## For developers
|
||||
Extending audiencekit's automation abilities is made as simple as possible. For example, a plugin that automatically changes "simp" to "shrimp" in every message (as seen by those consuming this project's output) is simply:
|
||||
```python
|
||||
from plugins import PluginBase
|
||||
from events import Message
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import Message
|
||||
|
||||
class Plugin(PluginBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def on_bus_event(self, event):
|
||||
if isinstance(event, Message):
|
||||
event.text.replace('simp', 'shrimp')
|
||||
return event
|
||||
```
|
||||
|
||||
Nearly all built-in functionality is written as plugins to help make them discoverable by example, so even if you're new to Python you should be able to copy + paste your way to a solution in no time (don't worry by the way, we all do that)!
|
||||
|
||||
Beside the plugin system, audiencekit's biggest feature is that it streams the events it sees (or generates internally) on a simple websocket, letting you use it as a quick way to pipe livestreaming data into anything else with websocket support!
|
||||
|
||||
|
||||
# Install
|
||||
**NOTE**: Compiled builds are in progress! You currently have to install this as if you were a developer (the hard way, but it's not that bad, I believe in you~)
|
||||
|
||||
## Manual / Development Install
|
||||
1. Install dependencies
|
||||
|
||||
You'll need Python 3.10, PDM, and some audio libraries. Not to fear though, there is [instructions on how to do that for your specific OS!](https://git.skeh.site/skeh/ovtk_audiencekit/wiki/Dependency-Setup)
|
||||
|
||||
2. Download the repository and open a terminal inside it
|
||||
|
||||
Extract the [tar](https://git.skeh.site/skeh/ovtk_audiencekit/archive/main.tar.gz) / [zip](https://git.skeh.site/skeh/ovtk_audiencekit/archive/main.zip), or clone via `git`~
|
||||
|
||||
3. Run `pdm sync`
|
||||
|
||||
Some dependencies may fail to install initially, but should be retried using a method that works automatically (your terminal should show "Retrying initially failed dependencies..." or similar).
|
||||
|
||||
If the install still fails, I've likely missed something in the os-specific install instructions, so please feel free to [open an issue](https://git.skeh.site/skeh/ovtk_audiencekit/issues/new) with your OS and the error you got!
|
||||
|
||||
# Installed! What now?
|
||||
As alluded to earlier, audiencekit does nearly *nothing* by default.
|
||||
|
||||
If you want some more tutorializing, head on over to the [Wiki](https://git.skeh.site/skeh/ovtk_audiencekit/wiki/Usage). If you'd rather a crash-course:
|
||||
|
||||
+ Read up a bit on [KDL's syntax][kdl] if you haven't already
|
||||
+ Crack open your favorite text editor and follow the instructions in the `config.kdl` file
|
||||
+ Open a terminal in the project location and start it using either:
|
||||
+ `pdm run start`, or
|
||||
+ [activate the venv](https://pdm.fming.dev/latest/usage/venv/#activate-a-virtualenv) once per terminal session, and then use `./audiencekit.py start` thereafter. This is recommended for development usage.
|
||||
+ Test your configuration using the `ws` subcommand (`pdm run ws` or `./audiencekit.py ws`)
|
||||
+ The built-in CLI will help for this and more! Just add "--help" to the end of anything, ie `./audiencekit.py --help`, `pdm run ws mkevent --help`, etc
|
||||
+ Make mistakes and [ask questions](https://birb.space/@skeh)!
|
||||
|
||||
-----------
|
||||
|
||||
Made with :heart: by the [Vtopia Collective][vtopia] and contributors
|
||||
|
||||
|
||||
[kdl]: https://kdl.dev
|
||||
[ovtk]: https://birb.space/@skeh/pages/ovtk-landing
|
||||
[vtopia]: https://opencollective.com/vtopia
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
from ovtk_audiencekit.cli import cli
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
|
@ -1,3 +0,0 @@
|
|||
from .group import cli
|
||||
from .start import start
|
||||
from .mkevent import mkevent
|
|
@ -1,5 +0,0 @@
|
|||
import click
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
|
@ -1,59 +0,0 @@
|
|||
import json
|
||||
|
||||
import click
|
||||
import websockets
|
||||
|
||||
from .group import cli
|
||||
from events import Message, Subscription
|
||||
from events.Message import USER_TYPE
|
||||
from chats.Twitch.Events import Raid
|
||||
from utils import make_sync
|
||||
|
||||
@make_sync
|
||||
async def send(data, ws):
|
||||
async with websockets.connect(ws) as websocket:
|
||||
await websocket.send(data)
|
||||
|
||||
|
||||
@cli.group()
|
||||
@click.option('--ws', default='ws://localhost:8080')
|
||||
@click.option('--via', default="console")
|
||||
@click.pass_context
|
||||
def mkevent(ctx, ws, via):
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['ws'] = ws
|
||||
ctx.obj['via'] = via
|
||||
|
||||
@mkevent.command(help="Make event via JSON")
|
||||
@click.argument('data_json')
|
||||
@click.pass_context
|
||||
def raw(ctx, data_json):
|
||||
send(data_json, ctx.obj['ws'])
|
||||
|
||||
@mkevent.command(help="Make generic chat message")
|
||||
@click.argument('username')
|
||||
@click.argument('text')
|
||||
@click.option('-t', '--type', 'user_type', default=USER_TYPE.SYSTEM.name,
|
||||
type=click.Choice([enum.name for enum in USER_TYPE]))
|
||||
@click.pass_context
|
||||
def msg(ctx, username, text, user_type=None):
|
||||
user_type = USER_TYPE[user_type]
|
||||
mockevent = Message(ctx.obj['via'], text, username, username, user_type)
|
||||
send(mockevent.serialize(), ctx.obj['ws'])
|
||||
|
||||
@mkevent.command(help="Make Twitch Raid")
|
||||
@click.argument('channel')
|
||||
@click.option('-c', '--count', type=click.IntRange(1, None), default=1)
|
||||
@click.pass_context
|
||||
def raid(ctx, channel, count=None):
|
||||
mockevent = Raid(ctx.obj['via'], channel, channel, count)
|
||||
send(mockevent.serialize(), ctx.obj['ws'])
|
||||
|
||||
@mkevent.command(help="Make subscription")
|
||||
@click.argument('user')
|
||||
@click.option('-s', '--streak', type=click.IntRange(0, None), default=0)
|
||||
@click.option('-g', '--gifted', multiple=True)
|
||||
@click.pass_context
|
||||
def sub(ctx, user, streak=None, gifted=None):
|
||||
mockevent = Subscription(ctx.obj['via'], user, user, streak=streak, gifted_to=gifted)
|
||||
send(mockevent.serialize(), ctx.obj['ws'])
|
87
cli/start.py
87
cli/start.py
|
@ -1,87 +0,0 @@
|
|||
import logging
|
||||
from multiprocessing import Event
|
||||
|
||||
import click
|
||||
from blessed import Terminal
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
|
||||
from .group import cli
|
||||
from core import MainProcess
|
||||
|
||||
term = Terminal()
|
||||
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
FORMATS = {
|
||||
logging.DEBUG: term.gray,
|
||||
logging.INFO: None,
|
||||
logging.WARNING: term.yellow,
|
||||
logging.ERROR: term.red,
|
||||
logging.CRITICAL: term.white_on_red_bold
|
||||
}
|
||||
|
||||
def __init__(self, show_time):
|
||||
format = "%(levelname)s:%(name)s (%(filename)s:%(lineno)d): %(message)s"
|
||||
if show_time:
|
||||
format = "%(asctime)s:" + format
|
||||
super().__init__(format)
|
||||
self.default_time_format = "%Y-%m-%dT%H:%M:%S"
|
||||
self.default_msec_format = None
|
||||
|
||||
def format(self, record):
|
||||
log = super().format(record)
|
||||
color = self.FORMATS.get(record.levelno)
|
||||
if color:
|
||||
return color(log)
|
||||
return log
|
||||
|
||||
|
||||
class ConfigChangeHandler(FileSystemEventHandler):
|
||||
def __init__(self, reload_event, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.reload_event = reload_event
|
||||
|
||||
def on_modified(self, fs_event):
|
||||
self.reload_event.set()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument('config_file', type=click.Path('r'), default='config.kdl')
|
||||
@click.option('--port', default='8080')
|
||||
@click.option('--bind', default='127.0.0.1')
|
||||
@click.option('--silent', 'loglevel', flag_value=logging.NOTSET)
|
||||
@click.option('--quiet', 'loglevel', flag_value=logging.ERROR)
|
||||
@click.option('--log-level-info--this-is-default-dont-use-this', 'loglevel',
|
||||
flag_value=logging.INFO, default=True, hidden=True)
|
||||
@click.option('--debug', 'loglevel', flag_value=logging.DEBUG)
|
||||
@click.option('--show-time/--no-time', default=False, help="Show time in logs")
|
||||
@click.option('--watch/--no-watch', default=True, help="Automatically reload on config changes")
|
||||
def start(config_file, loglevel, show_time=False, watch=True, port=None, bind=None):
|
||||
# Set root logger details
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(loglevel)
|
||||
log_handler = logging.StreamHandler()
|
||||
log_handler.setLevel(loglevel)
|
||||
log_handler.setFormatter(CustomFormatter(show_time))
|
||||
logger.addHandler(log_handler)
|
||||
# Quiet the depencency loggers
|
||||
logging.getLogger('websockets.server').setLevel(logging.INFO)
|
||||
logging.getLogger('websockets.client').setLevel(logging.INFO)
|
||||
logging.getLogger('asyncio').setLevel(logging.INFO)
|
||||
logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO)
|
||||
|
||||
reload_event = Event()
|
||||
if watch:
|
||||
handler = ConfigChangeHandler(reload_event)
|
||||
observer_thread = Observer()
|
||||
observer_thread.schedule(handler, config_file)
|
||||
observer_thread.start()
|
||||
|
||||
main = MainProcess(config_file, reload_event, port, bind)
|
||||
main.start()
|
||||
main.join()
|
||||
|
||||
if watch:
|
||||
observer_thread.stop()
|
||||
observer_thread.join()
|
90
config.kdl
90
config.kdl
|
@ -1,34 +1,80 @@
|
|||
/* Load config from another file at any point by doing:
|
||||
import "filename.kdl"
|
||||
*/
|
||||
|
||||
/* Comments surrounded by asterisks and slashes (like this one) are instructions,
|
||||
comments staring with two slashes are example configuration! */
|
||||
|
||||
/* Step 1: Add your credentials */
|
||||
secrets {
|
||||
/* Some features require authorization! Its recommended to place these in a
|
||||
seperate file and import them!!!!!!
|
||||
|
||||
For twitch, you can generate credentials via https://ovtk.skeh.site/twitch_auth
|
||||
and paste them in the tag below */
|
||||
/* Generate credentials via https://ovtk.skeh.site/twitch/auth
|
||||
and paste them between the curly braces below */
|
||||
Twitch {
|
||||
|
||||
}
|
||||
/* For YouTube, you need to download a client-secrets.json file and place it
|
||||
somewhere safe - see https://seo-michael.co.uk/how-to-create-your-own-youtube-api-key-id-and-secret
|
||||
Then, uncomment below, replacing the path to where you put your file */
|
||||
/* Generate a Youtube API key and download it as a json file. Place it somewhere safe!
|
||||
Then, uncomment below, filling in the path to your file.
|
||||
|
||||
See https://seo-michael.co.uk/how-to-create-your-own-youtube-api-key-id-and-secret */
|
||||
YoutubeLive {
|
||||
// client_secrets_path r"C:\path\To\Client\Secrets\File.json"
|
||||
}
|
||||
}
|
||||
|
||||
/* Uncomment (and fill in options) for Twitch
|
||||
channel_name: The name of the channel you wish to monitor (doesnt have to be yours)
|
||||
name (optional): The name of this chat (to differenciate when monitoring multiple) */
|
||||
// chat "Twitch" channel_name=""
|
||||
/* Step 2: Import modules
|
||||
|
||||
/* Uncomment for YoutubeLive */
|
||||
// chat "YoutubeLive"
|
||||
There are two types of modules, chats and plugins:
|
||||
+ Chats are self-explanitory: the event providers - livestream services.
|
||||
+ Plugins are the heart of the system, and can both monitor livestream events
|
||||
and be called by name in your config files to perform actions.
|
||||
|
||||
/* Silly testing chat (useful for testing scroll / bursts in a chat display)
|
||||
max_messages_per_chunk: The upper bound of messages to create per burst
|
||||
max_delay: The upper bound of how long to wait between chunks */
|
||||
// chat "FakeChat" max_messages_per_chunk=3 max_delay=10
|
||||
Some plugins (called "builtins" and always starting with a lowercase character)
|
||||
are always available, but others are not loaded until you ask to conserve
|
||||
resources. Chats are never loaded by default.
|
||||
|
||||
You can load either by using the "chat" or "plugin" node respectively,
|
||||
and supplying the name of the module. You can also rename them by seperating
|
||||
the module name and the new name with a colon (:)
|
||||
*/
|
||||
// chat "Twitch" channel_name="MyTwitchChannel"
|
||||
// chat "Twitch:otherguy" channel_name="SomeOtherChannel"
|
||||
// plugin "AudioAlert" output="ALSA:default"
|
||||
|
||||
|
||||
/* Step 3: Kick it
|
||||
|
||||
Some example automations are provided below to get you started.
|
||||
See the wiki for more details: https://git.skeh.site/skeh/ovtk_audiencekit/wiki
|
||||
*/
|
||||
|
||||
/* Self-promo every 10 min */
|
||||
// cue {
|
||||
// every hours=2 {
|
||||
// reply "Like the setup? Run it yourself! https://git.skeh.site/explore/repos?q=ovtk&topic=1"
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
/* Call an existing shoutout bot on raid */
|
||||
// trigger event="Raid" {
|
||||
// reply (arg)"!so {event.from_channel}"
|
||||
// }
|
||||
|
||||
/* Lurk command */
|
||||
// command "lurk" help="Lurke modeo" display=true {
|
||||
// do {
|
||||
// reply "The Twitch algorithm thanks you for the lurk~" display=true
|
||||
// }
|
||||
// }
|
||||
|
||||
/* TTS for every donation event */
|
||||
// plugin "TTS"
|
||||
// trigger monitization="0.01-" source="self" {
|
||||
// TTS (arg)"{event.user_name} says: {event.text}"
|
||||
// }
|
||||
|
||||
/* Voice effect channel redeem (via midi to physical effect rack / DAW) */
|
||||
// trigger event="ChannelPointRedemption" action="Sound Sussy" {
|
||||
// midi "sysex" data="FunEffect;1"
|
||||
// cue {
|
||||
// after minutes=5 {
|
||||
// midi "sysex" data="FunEffect;0"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
|
|
@ -1,215 +0,0 @@
|
|||
import importlib
|
||||
from multiprocessing import Process, Lock
|
||||
from multiprocessing.connection import wait
|
||||
import time
|
||||
from traceback import format_exception
|
||||
from queue import Queue, Empty
|
||||
import logging
|
||||
import os
|
||||
|
||||
import kdl
|
||||
|
||||
from core import WebsocketServerProcess
|
||||
from core.Config import kdl_parse_config, kdl_reserved
|
||||
from events import Event, Delete
|
||||
from chats.ChatProcess import ShutdownRequest
|
||||
# Builtin commands
|
||||
from plugins.Trigger import TriggerPlugin
|
||||
from plugins.Reply import ReplyPlugin
|
||||
from plugins.Command import CommandPlugin
|
||||
from plugins.Cue import CuePlugin
|
||||
from plugins.Write import WritePlugin
|
||||
from plugins.Exec import ExecPlugin
|
||||
from plugins.Chance import ChancePlugin
|
||||
from plugins.Choice import ChoicePlugin
|
||||
from plugins.Midi import MidiPlugin
|
||||
from plugins.WebSocket import WebSocketPlugin
|
||||
from plugins.PluginBase import PluginError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_kdl_deep(path, relativeto=None):
|
||||
if relativeto:
|
||||
path = os.path.normpath(os.path.join(relativeto, path))
|
||||
|
||||
with open(path, 'r') as f:
|
||||
config = kdl.parse(f.read(), kdl_parse_config)
|
||||
|
||||
for node in config.nodes:
|
||||
if node.name == 'import':
|
||||
yield from parse_kdl_deep(node.args[0], relativeto=os.path.dirname(path))
|
||||
else:
|
||||
yield node
|
||||
|
||||
|
||||
class MainProcess(Process):
|
||||
def __init__(self, config_path, reload_event, port, bind):
|
||||
super().__init__()
|
||||
self.config_path = config_path
|
||||
self.reload_event = reload_event
|
||||
self.port = port
|
||||
self.bind = bind
|
||||
|
||||
@staticmethod
|
||||
def get_external_module_names(node, store):
|
||||
if len(node.args) == 0:
|
||||
raise ValueError(f'Invalid arguments - usage: {node.name} module')
|
||||
nameparts = node.args[0].split(':')
|
||||
module_name = nameparts[0]
|
||||
instance_name = nameparts[1] if len(nameparts) == 2 else module_name
|
||||
if store.get(instance_name):
|
||||
if instance_name != module_name:
|
||||
raise ValueError(f"Multiple nodes named {instance_name}, please specify unique names as the second argument")
|
||||
else:
|
||||
raise ValueError(f"Multiple definitions of {instance_name} exist, please specify unique names as the second argument")
|
||||
|
||||
return module_name, instance_name
|
||||
|
||||
def handle_event(self, event):
|
||||
logger.info(event)
|
||||
|
||||
if isinstance(event, Event):
|
||||
for plugin_name, plugin in list(self.plugins.items()):
|
||||
try:
|
||||
event = plugin.on_bus_event(event)
|
||||
logger.debug(f'Event after {plugin_name} - {event}')
|
||||
except PluginError as e:
|
||||
logger.critical(f'Failure when processing {e.source} ({e}) - disabling...')
|
||||
logger.debug(''.join(format_exception(None, e, e.__traceback__)))
|
||||
self.plugins[e.source].__del__()
|
||||
del self.plugins[e.source]
|
||||
except Exception as e:
|
||||
logger.critical(f'Failure when processing {plugin_name} ({e}) - disabling...')
|
||||
logger.debug(''.join(format_exception(None, e, e.__traceback__)))
|
||||
self.plugins[plugin_name].__del__()
|
||||
del self.plugins[plugin_name]
|
||||
if event is None:
|
||||
break
|
||||
else:
|
||||
self.server_process.message_pipe.send(event)
|
||||
elif isinstance(event, Delete):
|
||||
self.server_process.message_pipe.send(event)
|
||||
else:
|
||||
logger.error(f'Unknown data in event loop - {event}')
|
||||
|
||||
def setup(self):
|
||||
config = kdl.Document(list(parse_kdl_deep(self.config_path)))
|
||||
|
||||
stdin_lock = Lock()
|
||||
# Load secrets
|
||||
secrets = {}
|
||||
if node := config.get('secrets'):
|
||||
for module in node.nodes:
|
||||
fields = secrets.get(module.name, {})
|
||||
for node in module.nodes:
|
||||
fields[node.name] = node.args[0] if len(node.args) == 1 else node.args
|
||||
secrets[module.name] = fields
|
||||
|
||||
# Dynamically import chats
|
||||
self.chat_processes = {}
|
||||
for node in config.getAll('chat'):
|
||||
module_name, chat_name = self.get_external_module_names(node, self.chat_processes)
|
||||
secrets_for_mod = secrets.get(module_name, {})
|
||||
|
||||
try:
|
||||
chat_module = importlib.import_module(f'.{module_name}', package='chats')
|
||||
chat_process = chat_module.Process(stdin_lock, chat_name, **node.props, **secrets_for_mod)
|
||||
self.chat_processes[chat_name] = chat_process
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to initalize {module_name} module "{chat_name}" - {e}')
|
||||
|
||||
if len(self.chat_processes.keys()) == 0:
|
||||
logger.warning('No chats configured!')
|
||||
|
||||
# Start chat processes
|
||||
for process in self.chat_processes.values():
|
||||
process.start()
|
||||
|
||||
# Load plugins
|
||||
self.plugin_generated_events = Queue()
|
||||
## Builtins
|
||||
self.plugins = {
|
||||
'trigger': TriggerPlugin(self.chat_processes, self.plugin_generated_events, 'trigger'),
|
||||
'reply': ReplyPlugin(self.chat_processes, self.plugin_generated_events, 'reply'),
|
||||
'command': CommandPlugin(self.chat_processes, self.plugin_generated_events, 'command'),
|
||||
'cue': CuePlugin(self.chat_processes, self.plugin_generated_events, 'cue'),
|
||||
'write': WritePlugin(self.chat_processes, self.plugin_generated_events, 'write'),
|
||||
'exec': ExecPlugin(self.chat_processes, self.plugin_generated_events, 'exec'),
|
||||
'chance': ChancePlugin(self.chat_processes, self.plugin_generated_events, 'chance'),
|
||||
'choice': ChoicePlugin(self.chat_processes, self.plugin_generated_events, 'choice'),
|
||||
'midi': MidiPlugin(self.chat_processes, self.plugin_generated_events, 'midi'),
|
||||
'ws': WebSocketPlugin(self.chat_processes, self.plugin_generated_events, 'ws'),
|
||||
}
|
||||
## Dynamic
|
||||
for node in config.getAll('plugin'):
|
||||
module_name, plugin_name = self.get_external_module_names(node, self.plugins)
|
||||
secrets_for_mod = secrets.get(module_name, {})
|
||||
|
||||
try:
|
||||
plugin_module = importlib.import_module(f'.{module_name}', package='plugins')
|
||||
plugin = plugin_module.Plugin(self.chat_processes, self.plugin_generated_events,
|
||||
plugin_name, **node.props, **secrets_for_mod, _children=node.nodes)
|
||||
self.plugins[plugin_name] = plugin
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to initalize {module_name} plugin "{plugin_name}" - {e}')
|
||||
|
||||
# Run plugin definitions
|
||||
for node in config.nodes:
|
||||
if node.name in kdl_reserved:
|
||||
continue
|
||||
plugin_name = node.name
|
||||
plugin_module = self.plugins.get(plugin_name)
|
||||
if plugin_module is None:
|
||||
logger.error(f'Unknown plugin: {node.name}')
|
||||
else:
|
||||
plugin_module._run(*node.args, **node.props, _children=node.nodes)
|
||||
|
||||
# Register watchable handles
|
||||
self.pipes = [process.event_pipe for process in self.chat_processes.values()]
|
||||
self.pipes.append(self.server_process.message_pipe)
|
||||
|
||||
def run(self):
|
||||
# Start websocket server
|
||||
self.server_process = WebsocketServerProcess(self.port, self.bind)
|
||||
self.server_process.start()
|
||||
try:
|
||||
# Do initial setup
|
||||
self.setup()
|
||||
|
||||
# Event loop
|
||||
last_tick = time.time()
|
||||
while True:
|
||||
ready_pipes = wait(self.pipes, timeout=0.5)
|
||||
|
||||
dt = time.time() - last_tick
|
||||
for plugin in self.plugins.values():
|
||||
plugin.tick(dt)
|
||||
last_tick = time.time()
|
||||
|
||||
for ready_pipe in ready_pipes:
|
||||
event = ready_pipe.recv()
|
||||
self.handle_event(event)
|
||||
|
||||
while not self.plugin_generated_events.empty():
|
||||
try:
|
||||
event = self.plugin_generated_events.get_nowait()
|
||||
self.handle_event(event)
|
||||
except Empty:
|
||||
break
|
||||
|
||||
if self.reload_event.is_set():
|
||||
self.reload_event.clear()
|
||||
logger.info('Reloading...')
|
||||
self.setup()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.critical(f'Failure in core process - {e}')
|
||||
logger.debug(''.join(format_exception(None, e, e.__traceback__)))
|
||||
finally:
|
||||
for process in self.chat_processes.values():
|
||||
process.control_pipe.send(ShutdownRequest('root'))
|
||||
process.join(5)
|
||||
if process.exitcode is None:
|
||||
process.terminate()
|
||||
self.server_process.terminate()
|
|
@ -1,21 +0,0 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from events import Event
|
||||
|
||||
|
||||
@dataclass
|
||||
class Subscription(Event):
|
||||
user_name: str
|
||||
user_id: str
|
||||
gifted_to: list = None
|
||||
tier: str = None
|
||||
resub: bool = False
|
||||
streak: int = None
|
||||
total_months: int = None
|
||||
|
||||
def __repr__(self):
|
||||
if self.gifted_to:
|
||||
recipent = ', '.join(user['user_name'] for user in self.gifted_to)
|
||||
else:
|
||||
recipent = self.user_name
|
||||
return f"Subcription from {self.user_name or 'anonymous'} to {recipent} - tier = {self.tier}"
|
|
@ -1,97 +0,0 @@
|
|||
import soundfile
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pyaudio as pya
|
||||
|
||||
from plugins import PluginBase
|
||||
|
||||
# HACK: Redirect stderr to /dev/null to silence portaudio boot
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
old_stderr = os.dup(2)
|
||||
sys.stderr.flush()
|
||||
os.dup2(devnull, 2)
|
||||
os.close(devnull)
|
||||
|
||||
pyaudio = pya.PyAudio()
|
||||
|
||||
# HACK: Put stderr back where it was (i think)
|
||||
os.dup2(old_stderr, 2)
|
||||
os.close(old_stderr)
|
||||
|
||||
|
||||
class Clip:
|
||||
def __init__(self, path, output_index, buffer_length, cutoff_prevent_length):
|
||||
self._sound = soundfile.SoundFile(path)
|
||||
self._cutoff_prevent_length = cutoff_prevent_length
|
||||
|
||||
self._cutoff_prevent_count = 0
|
||||
self._stream = pyaudio.open(
|
||||
output_device_index=output_index,
|
||||
format=pya.paFloat32,
|
||||
channels=self._sound.channels,
|
||||
rate=self._sound.samplerate,
|
||||
frames_per_buffer=buffer_length,
|
||||
output=True,
|
||||
stream_callback=self._read_callback)
|
||||
|
||||
def play(self):
|
||||
self._sound.seek(0)
|
||||
self._cutoff_prevent_count = 0
|
||||
|
||||
if not self._stream.is_active():
|
||||
self._stream.stop_stream()
|
||||
self._stream.start_stream()
|
||||
|
||||
def _read_callback(self, in_data, frame_count, time_info, status):
|
||||
if self._sound.tell() == self._sound.frames:
|
||||
self._cutoff_prevent_count += 1
|
||||
return np.zeros((frame_count, self._sound.channels)), pya.paContinue if self._cutoff_prevent_count < self._cutoff_prevent_length else pya.paComplete
|
||||
|
||||
return self._sound.read(frames=frame_count, dtype='float32', fill_value=0), pya.paContinue
|
||||
|
||||
|
||||
class AudioAlert(PluginBase):
|
||||
def __init__(self, *args, output=None, buffer_length=2048, cutoff_prevention_buffers=2, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.sounds = {}
|
||||
|
||||
self._cutoff_prevent_length = cutoff_prevention_buffers
|
||||
self._buffer_length = int(buffer_length)
|
||||
|
||||
if output is not None:
|
||||
if ':' in output:
|
||||
host_api_name, output_name = output.split(':', 1)
|
||||
else:
|
||||
host_api_name = output
|
||||
output_name = None
|
||||
|
||||
for i in range(pyaudio.get_host_api_count()):
|
||||
host_api_info = pyaudio.get_host_api_info_by_index(i)
|
||||
if host_api_info['name'] == host_api_name:
|
||||
if output_name is None:
|
||||
output_index = host_api_info['defaultOutputDevice']
|
||||
break
|
||||
else:
|
||||
for j in range(host_api_info['deviceCount']):
|
||||
device_info = pyaudio.get_device_info_by_host_api_device_index(i, j)
|
||||
if device_info['name'] == output_name:
|
||||
output_index = device_info['index']
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'Could not find requested output device: {output_name}')
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'Could not find requested audio API: {host_api_name}')
|
||||
else:
|
||||
output_index = None
|
||||
|
||||
self._output_index = output_index
|
||||
|
||||
def run(self, path, **kwargs):
|
||||
if sound := self.sounds.get(path):
|
||||
sound.play()
|
||||
else:
|
||||
self.sounds[path] = Clip(path, self._output_index, self._buffer_length, self._cutoff_prevent_length)
|
|
@ -1,56 +0,0 @@
|
|||
import maya
|
||||
|
||||
from plugins import PluginBase
|
||||
|
||||
|
||||
class CueEvent:
|
||||
def __init__(self, oneshot, at=None, **kwargs):
|
||||
self.oneshot = oneshot
|
||||
|
||||
if at:
|
||||
self._next_run = maya.parse(at)
|
||||
self._interval = None
|
||||
else:
|
||||
self._next_run = maya.now().add(**kwargs)
|
||||
self._interval = kwargs
|
||||
|
||||
def check(self):
|
||||
if self._next_run <= maya.now():
|
||||
if self._interval:
|
||||
self._next_run = maya.now().add(**self._interval)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class CuePlugin(PluginBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.cue_events = {}
|
||||
|
||||
def run(self, name=None, _children=None, **kwargs):
|
||||
super().run(**kwargs)
|
||||
if not _children:
|
||||
raise ValueError('Cue defined without any events')
|
||||
|
||||
if name is None:
|
||||
name = f"cue-{len(self.cue_events.keys())}"
|
||||
|
||||
for eventnode in _children:
|
||||
at = eventnode.args[0] if len(eventnode.args) == 1 else None
|
||||
oneshot = eventnode.name in ['once', 'after']
|
||||
cue_event = CueEvent(oneshot, at=at, **eventnode.props)
|
||||
|
||||
actions = [lambda node=node: self.call_plugin_from_kdl(node) for node in eventnode.nodes];
|
||||
self.cue_events[name] = (cue_event, actions)
|
||||
|
||||
def on_control_event(self, event):
|
||||
if isinstance(event, DisableEvent):
|
||||
del self.cue_events[event.target]
|
||||
|
||||
def tick(self, dt):
|
||||
for key, (event, actions) in list(self.cue_events.items()):
|
||||
if event.check():
|
||||
for action in actions:
|
||||
action()
|
||||
if event.oneshot:
|
||||
del self.cue_events[key]
|
|
@ -1,20 +0,0 @@
|
|||
import subprocess
|
||||
|
||||
import mido
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import SysMessage
|
||||
|
||||
|
||||
class MidiPlugin(PluginBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.output_port = mido.open_output()
|
||||
|
||||
def run(self, type, _ctx={}, **kwargs):
|
||||
if type == 'sysex':
|
||||
data = kwargs['data']
|
||||
msg = mido.Message('sysex', data=bytes(data, encoding='utf-8'), time=0)
|
||||
self.output_port.send(msg)
|
||||
else:
|
||||
raise NotImplimented('TODO: note on/off and cc')
|
|
@ -1,118 +0,0 @@
|
|||
from dataclasses import asdict
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import reduce
|
||||
from operator import getitem
|
||||
import logging
|
||||
|
||||
import kdl
|
||||
|
||||
from core.Config import kdl_parse_config
|
||||
|
||||
|
||||
class PluginError(Exception):
|
||||
def __init__(self, source, message):
|
||||
self.source = source
|
||||
self.message = message
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
|
||||
class PluginBase(ABC):
|
||||
plugins = {}
|
||||
|
||||
def __init__(self, chat_processes, event_queue, name, _children=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.chats = chat_processes
|
||||
self._event_queue = event_queue
|
||||
self._name = name
|
||||
|
||||
self.logger = logging.getLogger(f'plugin.{self._name}')
|
||||
self.plugins[name] = self
|
||||
if _children:
|
||||
raise ValueError('Module does not accept children')
|
||||
|
||||
def __del__(self):
|
||||
if self.plugins.get(self._name) == self:
|
||||
del self.plugins[self._name]
|
||||
|
||||
# Base class helpers
|
||||
def broadcast(self, event):
|
||||
"""Send event to every active chat"""
|
||||
for proc in self.chats.values():
|
||||
if proc.readonly:
|
||||
continue
|
||||
proc.control_pipe.send(event)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _kdl_arg_formatter(text, fragment, args):
|
||||
key = fragment.fragment[1:-1]
|
||||
if '{' in key:
|
||||
return key.format(**args).replace(r'\"', '"')
|
||||
else:
|
||||
try:
|
||||
return reduce(getitem, key.split('.'), args)
|
||||
except KeyError as e:
|
||||
raise ValueError(f'{key} does not exist!') from e
|
||||
|
||||
def fill_context(self, actionnode, ctx):
|
||||
config = asdict(kdl_parse_config)
|
||||
config['valueConverters'] = {
|
||||
**config['valueConverters'],
|
||||
'arg': lambda text, fragment, args=ctx: self._kdl_arg_formatter(text, fragment, args),
|
||||
}
|
||||
config = kdl.ParseConfig(**config)
|
||||
|
||||
newnode = kdl.parse(str(actionnode), config).nodes[0]
|
||||
return newnode
|
||||
|
||||
def call_plugin_from_kdl(self, node, *args, _ctx={}, **kwargs):
|
||||
"""
|
||||
Calls some other plugin as configured by the passed KDL node
|
||||
If this was done in response to an event, pass it as event in _ctx!
|
||||
"""
|
||||
node = self.fill_context(node, _ctx)
|
||||
target = self.plugins.get(node.name)
|
||||
if target is None:
|
||||
self.logger.warning(f'Could not find plugin or builtin with name {node.name}')
|
||||
else:
|
||||
return target._run(*node.args, *args, **node.props, _ctx=_ctx, **kwargs, _children=node.nodes)
|
||||
|
||||
def send_to_bus(self, event):
|
||||
"""
|
||||
Send an event to the event bus
|
||||
WARNING: This will cause the event to be processed by other plugins - be careful not to cause an infinite loop!
|
||||
"""
|
||||
self._event_queue.put(event)
|
||||
|
||||
def _run(self, *args, **kwargs):
|
||||
try:
|
||||
return self.run(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if isinstance(e, KeyboardInterrupt):
|
||||
raise e
|
||||
raise PluginError(self._name, str(e)) from e
|
||||
|
||||
# User-defined
|
||||
def tick(self, dt):
|
||||
"""Called at least every half second - perform time-dependent updates here!"""
|
||||
pass
|
||||
|
||||
def on_bus_event(self, event):
|
||||
"""Called for every event from the chats"""
|
||||
return event
|
||||
|
||||
def on_control_event(self, event):
|
||||
"""
|
||||
Called for events targeting this plugin name specifically.
|
||||
This is normally used for other applications to communicate with this one over the websocket interface
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def run(self, _children=None, _ctx={}, **kwargs):
|
||||
"""
|
||||
Run plugin action, either due to a definition in the config, or due to another plugin
|
||||
"""
|
||||
pass
|
|
@ -1,2 +0,0 @@
|
|||
from .PluginBase import PluginBase
|
||||
from .Command import Command
|
|
@ -0,0 +1,60 @@
|
|||
[project]
|
||||
name = "ovtk_audiencekit"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Skeh", email = "im@skeh.site"},
|
||||
]
|
||||
dependencies = [
|
||||
"click",
|
||||
"kdl-py",
|
||||
"quart==0.18.*",
|
||||
"hypercorn",
|
||||
"requests",
|
||||
"websockets",
|
||||
"aioprocessing",
|
||||
"aioscheduler",
|
||||
"pyaudio==0.2.*",
|
||||
"librosa==0.8.*",
|
||||
"pytsmod",
|
||||
"numpy",
|
||||
"multipledispatch",
|
||||
"blessed",
|
||||
"appdirs",
|
||||
"maya",
|
||||
"mido",
|
||||
"python-rtmidi",
|
||||
"simpleobsws",
|
||||
"python-osc>=1.9.0",
|
||||
]
|
||||
requires-python = ">=3.10,<3.11"
|
||||
readme = "README.md"
|
||||
license = {text = "GPLv2"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
tts = [
|
||||
"TTS==0.9.*",
|
||||
"torch==1.13.*",
|
||||
]
|
||||
phrasecounter = ["num2words"]
|
||||
jail = ["owoify-py==2.*"]
|
||||
twitch = ["miniirc"]
|
||||
|
||||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"pipenv-setup",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[tool.pdm.scripts]
|
||||
start = "python audiencekit.py start"
|
||||
ws = "python audiencekit.py ws"
|
||||
|
||||
[tool.pdm]
|
||||
[[tool.pdm.source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
|
@ -1,12 +1,12 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from multiprocessing import Process, Pipe, Manager
|
||||
from traceback import format_exception
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
from events import Event
|
||||
from ovtk_audiencekit.events import Event
|
||||
from ovtk_audiencekit.utils import format_exception
|
||||
|
||||
|
||||
class GracefulShutdownException(Exception):
|
||||
|
@ -14,7 +14,7 @@ class GracefulShutdownException(Exception):
|
|||
|
||||
|
||||
class ShutdownRequest(Event):
|
||||
pass
|
||||
_hidden = True
|
||||
|
||||
|
||||
class ChatProcess(Process, ABC):
|
||||
|
@ -133,7 +133,7 @@ class ChatProcess(Process, ABC):
|
|||
return 0
|
||||
except Exception as e:
|
||||
self.logger.error(f'Uncaught exception in {self._name}: {e}')
|
||||
self.logger.debug(''.join(format_exception(None, e, e.__traceback__)))
|
||||
self.logger.debug(format_exception(e))
|
||||
return 1
|
||||
finally:
|
||||
self.on_exit()
|
|
@ -1,9 +1,9 @@
|
|||
import random
|
||||
from enum import Enum, auto
|
||||
|
||||
from chats import ChatProcess
|
||||
from events import Event, Message, SysMessage
|
||||
from events.Message import USER_TYPE
|
||||
from ovtk_audiencekit.chats import ChatProcess
|
||||
from ovtk_audiencekit.events import Event, Message, SysMessage
|
||||
from ovtk_audiencekit.events.Message import USER_TYPE
|
||||
|
||||
|
||||
class STATES(Enum):
|
|
@ -4,9 +4,9 @@ import logging
|
|||
from enum import Enum, auto
|
||||
from itertools import chain
|
||||
|
||||
from chats import ChatProcess
|
||||
from utils import NonBlockingWebsocket
|
||||
from events.Message import Message, USER_TYPE
|
||||
from ovtk_audiencekit.chats import ChatProcess
|
||||
from ovtk_audiencekit.utils import NonBlockingWebsocket
|
||||
from ovtk_audiencekit.events.Message import Message, USER_TYPE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -29,13 +29,13 @@ class MisskeyProcess(ChatProcess):
|
|||
self.state = STATES.CONNECTING
|
||||
|
||||
def normalize_event(self, event):
|
||||
user_name = event['user']['name']
|
||||
user_name = event['user']['name'] or event['user']['username']
|
||||
user_id = event['user']['id']
|
||||
text = event.get('text', '')
|
||||
attachments = [(file['type'], file['url']) for file in event.get('files', [])]
|
||||
emojis = {emoji['name']: emoji['url'] for emoji in chain(event.get('emojis', []), event['user'].get('emojis', []))}
|
||||
if text or attachments:
|
||||
msg = Message(self._name, text,
|
||||
msg = Message(self._name, text or '',
|
||||
user_name, user_id, USER_TYPE.USER,
|
||||
id=event['id'], emotes=emojis or None,
|
||||
attachments=attachments or None)
|
|
@ -0,0 +1 @@
|
|||
from .peertube import PtChatProcess as Process
|
|
@ -0,0 +1,70 @@
|
|||
import json
|
||||
import random
|
||||
import logging
|
||||
from enum import Enum, auto
|
||||
from itertools import chain
|
||||
|
||||
import socketio
|
||||
import requests
|
||||
|
||||
from ovtk_audiencekit.chats import ChatProcess
|
||||
from ovtk_audiencekit.events.Message import Message, USER_TYPE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class STATES(Enum):
|
||||
CONNECTING = auto()
|
||||
READING = auto()
|
||||
|
||||
|
||||
class PtChatProcess(ChatProcess):
|
||||
def __init__(self, *args, instance_url=None, channel=None, token=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance_url = instance_url
|
||||
self.channel = channel
|
||||
|
||||
self._state_machine = self.bind_to_states(STATES)
|
||||
self.state = STATES.CONNECTING
|
||||
|
||||
def normalize_event(self, event):
|
||||
message = event['message']
|
||||
user_name = message['account']['displayName'] or message['account']['name']
|
||||
user_id = message['account']['id']
|
||||
text = message.get('text', '')
|
||||
if text:
|
||||
msg = Message(self._name, text,
|
||||
user_name, user_id, USER_TYPE.USER,
|
||||
id=message['id'])
|
||||
return msg
|
||||
return None
|
||||
|
||||
def on_connecting(self, next_state):
|
||||
self._session = requests.Session()
|
||||
self._sio = socketio.Client()
|
||||
|
||||
api_channel = self._session.get(f'{self.instance_url}/api/v1/video-channels/{self.channel}')
|
||||
api_channel.raise_for_status()
|
||||
room_id = api_channel.json().get('roomId')
|
||||
if room_id is None:
|
||||
raise ValueError('No chatroom returned from channel api!')
|
||||
|
||||
self._sio.connect(self.instance_url, namespaces=['/live-chat'], wait=True)
|
||||
self._sio.emit('subscribe', data={'roomId': room_id}, namespace='/live-chat')
|
||||
self._sio.on('new-message', self.handle_message, namespace='/live-chat')
|
||||
|
||||
return STATES.READING
|
||||
|
||||
def handle_message(self, data):
|
||||
try:
|
||||
norm = self.normalize_event(data)
|
||||
self.publish(norm)
|
||||
except Exception:
|
||||
logger.error(f'Failed to process message: {data}')
|
||||
|
||||
def on_reading(self, next_state, timeout=0.5):
|
||||
self._sio.sleep(timeout)
|
||||
return 0
|
||||
|
||||
def loop(self, next_state):
|
||||
return self._state_machine(self.state, next_state)
|
|
@ -1,6 +1,6 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from events import Event
|
||||
from ovtk_audiencekit.events import Event
|
||||
|
||||
# TODO: Include list of raider usernames (for hate raid moderation)
|
||||
@dataclass
|
|
@ -2,8 +2,8 @@ import time
|
|||
from enum import Enum, auto
|
||||
from itertools import chain
|
||||
|
||||
from chats import ChatProcess
|
||||
from events import Message, SysMessage
|
||||
from ovtk_audiencekit.chats import ChatProcess
|
||||
from ovtk_audiencekit.events import Message, SysMessage
|
||||
|
||||
from .sources.TwitchIRC import TwitchIRC, TwitchIRCException
|
||||
from .sources.TwitchAPI import TwitchAPI
|
||||
|
@ -26,7 +26,7 @@ class TwitchProcess(ChatProcess):
|
|||
# IRC options
|
||||
botname=None, emote_res=4.0,
|
||||
# EventSub options
|
||||
eventsub_host='wss://ovtk.skeh.site/twitch',
|
||||
eventsub=True, eventsub_host='wss://ovtk.skeh.site/twitch',
|
||||
# Inheritance boilerplate
|
||||
**kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -53,10 +53,17 @@ class TwitchProcess(ChatProcess):
|
|||
|
||||
self.shared.target_data = target_data
|
||||
self.shared.users = []
|
||||
|
||||
self._sources = []
|
||||
self.irc = TwitchIRC(self._channel_name, self._username, self._token, cheermotes, emote_res, self.shared)
|
||||
self.eventsub = TwitchEventSub(self.api, eventsub_host)
|
||||
self._sources.append(self.irc)
|
||||
if eventsub:
|
||||
self.eventsub = TwitchEventSub(self.api, eventsub_host)
|
||||
self._sources.append(self.eventsub)
|
||||
|
||||
self.bttv = BTTV(target_data['user']['id'])
|
||||
|
||||
|
||||
def loop(self, next_state):
|
||||
return self._state_machine(self.state, next_state)
|
||||
|
||||
|
@ -82,12 +89,13 @@ class TwitchProcess(ChatProcess):
|
|||
|
||||
def on_connecting(self, next_state):
|
||||
self.irc.connect()
|
||||
self.eventsub.subscribe(self._channel_name)
|
||||
if self.__dict__.get('eventsub'):
|
||||
self.eventsub.subscribe(self._channel_name)
|
||||
return STATES.READING
|
||||
|
||||
def on_reading(self, next_state):
|
||||
try:
|
||||
for event in chain(self.irc.read(0.1), self.eventsub.read(0.1)):
|
||||
for event in chain(*(source.read(0.1) for source in self._sources)):
|
||||
# Retarget event
|
||||
event.via = self._name
|
||||
if isinstance(event, Message):
|
|
@ -1,10 +1,11 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from utils import NonBlockingWebsocket
|
||||
from ovtk_audiencekit.utils import NonBlockingWebsocket
|
||||
from ovtk_audiencekit.core.Config import ovtk_user_id
|
||||
from ovtk_audiencekit.events import Follow
|
||||
|
||||
from ..Events import ChannelPointRedemption
|
||||
from core.Config import ovtk_user_id
|
||||
from events import Follow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -5,11 +5,12 @@ from itertools import chain, islice
|
|||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
import websockets.exceptions
|
||||
from miniirc import ircv3_message_parser
|
||||
|
||||
from events.Message import Message, USER_TYPE
|
||||
from events import Subscription
|
||||
from utils import NonBlockingWebsocket
|
||||
from ovtk_audiencekit.events.Message import Message, USER_TYPE
|
||||
from ovtk_audiencekit.events import Subscription
|
||||
from ovtk_audiencekit.utils import NonBlockingWebsocket
|
||||
|
||||
from ..Events import Raid
|
||||
|
||||
|
@ -40,6 +41,7 @@ class TwitchIRC:
|
|||
self.users = shared.users
|
||||
self._reply_buffer_maxlen = 1000
|
||||
self._reply_buffer = OrderedDict()
|
||||
self._group_gifts = {}
|
||||
|
||||
def connect(self):
|
||||
self._ws = NonBlockingWebsocket(WEBSOCKET_ADDRESS)
|
||||
|
@ -56,33 +58,37 @@ class TwitchIRC:
|
|||
raise TwitchIRCException(f'Got bad response during auth: {response}')
|
||||
|
||||
def read(self, timeout):
|
||||
if self._ws.poll(timeout):
|
||||
messages = self._ws.recv()
|
||||
for message in messages.splitlines():
|
||||
normalized = None
|
||||
cmd, hostmask, tags, args = ircv3_message_parser(message)
|
||||
logger.debug(f'cmd: {cmd}, hostmask: {hostmask}, args: {args}, tags: {tags}')
|
||||
if cmd == 'PRIVMSG':
|
||||
normalized = self.normalize_message(hostmask, tags, args)
|
||||
elif cmd == 'USERNOTICE':
|
||||
normalized = self.normalize_event(hostmask, tags, args)
|
||||
elif cmd == 'PING':
|
||||
self._ws.send(f"PONG {' '.join(args)}")
|
||||
elif cmd == 'RECONNECT':
|
||||
raise TimeoutError('Twitch API requested timeout')
|
||||
elif cmd == 'JOIN':
|
||||
self.users.append(hostmask[0])
|
||||
elif cmd == 'PART':
|
||||
try:
|
||||
self.users.remove(hostmask[0])
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
if self._ws.poll(timeout):
|
||||
messages = self._ws.recv()
|
||||
for message in messages.splitlines():
|
||||
normalized = None
|
||||
cmd, hostmask, tags, args = ircv3_message_parser(message)
|
||||
logger.debug(f'cmd: {cmd}, hostmask: {hostmask}, args: {args}, tags: {tags}')
|
||||
if cmd == 'PRIVMSG':
|
||||
normalized = self.normalize_message(hostmask, tags, args)
|
||||
elif cmd == 'USERNOTICE':
|
||||
normalized = self.normalize_event(hostmask, tags, args)
|
||||
elif cmd == 'PING':
|
||||
self._ws.send(f"PONG {' '.join(args)}")
|
||||
elif cmd == 'RECONNECT':
|
||||
raise TimeoutError('Twitch API requested timeout')
|
||||
elif cmd == 'JOIN':
|
||||
self.users.append(hostmask[0])
|
||||
elif cmd == 'PART':
|
||||
try:
|
||||
self.users.remove(hostmask[0])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if normalized:
|
||||
self._reply_buffer[normalized.id] = normalized
|
||||
if len(self._reply_buffer.keys()) > self._reply_buffer_maxlen:
|
||||
self._reply_buffer.popitem()
|
||||
yield normalized
|
||||
if normalized:
|
||||
self._reply_buffer[normalized.id] = normalized
|
||||
if len(self._reply_buffer.keys()) > self._reply_buffer_maxlen:
|
||||
self._reply_buffer.popitem()
|
||||
yield normalized
|
||||
except websockets.exceptions.ConnectionClosedError:
|
||||
self.logger.info('Twitch websocket disconnected - trying reconnet')
|
||||
self.connect()
|
||||
|
||||
def send(self, message):
|
||||
irc_msg = f'PRIVMSG #{self._username} :{message}'
|
|
@ -5,8 +5,8 @@ from enum import Enum, auto
|
|||
|
||||
import requests
|
||||
|
||||
from chats import ChatProcess
|
||||
from events.Message import Message, SysMessage, USER_TYPE
|
||||
from ovtk_audiencekit.chats import ChatProcess
|
||||
from ovtk_audiencekit.events.Message import Message, SysMessage, USER_TYPE
|
||||
|
||||
|
||||
class STATES(Enum):
|
|
@ -1 +1,3 @@
|
|||
from .ChatProcess import ChatProcess
|
||||
|
||||
__all__ = ['ChatProcess']
|
|
@ -0,0 +1,5 @@
|
|||
from .group import cli
|
||||
from .start import start
|
||||
from .websocketutils import websocketutils
|
||||
|
||||
__all__ = ['cli', 'start', 'websocketutils']
|
|
@ -0,0 +1,64 @@
|
|||
import logging
|
||||
import warnings
|
||||
|
||||
import click
|
||||
from blessed import Terminal
|
||||
|
||||
term = Terminal()
|
||||
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
FORMATS = {
|
||||
logging.DEBUG: term.gray,
|
||||
logging.INFO: None,
|
||||
logging.WARNING: term.yellow,
|
||||
logging.ERROR: term.red,
|
||||
logging.CRITICAL: term.white_on_red_bold
|
||||
}
|
||||
|
||||
def __init__(self, show_time, show_loc):
|
||||
if show_loc:
|
||||
format = "%(levelname)s:%(name)s (%(filename)s:%(lineno)d): %(message)s"
|
||||
else:
|
||||
format = "%(levelname)s:%(name)s: %(message)s"
|
||||
if show_time:
|
||||
format = "%(asctime)s:" + format
|
||||
super().__init__(format)
|
||||
self.default_time_format = "%Y-%m-%dT%H:%M:%S"
|
||||
self.default_msec_format = None
|
||||
|
||||
def format(self, record):
|
||||
log = super().format(record)
|
||||
color = self.FORMATS.get(record.levelno)
|
||||
if color:
|
||||
return color(log)
|
||||
return log
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option('--quiet', 'loglevel', flag_value=logging.ERROR)
|
||||
@click.option('--log-level-info--this-is-default-dont-use-this', 'loglevel',
|
||||
flag_value=logging.INFO, default=True, hidden=True)
|
||||
@click.option('--debug', 'loglevel', flag_value=logging.DEBUG)
|
||||
@click.option('--show-time/--no-time', default=False, help="Show time in logs")
|
||||
def cli(loglevel, show_time=False):
|
||||
# Set root logger details
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(loglevel)
|
||||
log_handler = logging.StreamHandler()
|
||||
log_handler.setLevel(loglevel)
|
||||
log_handler.setFormatter(CustomFormatter(show_time, loglevel==logging.DEBUG))
|
||||
logger.addHandler(log_handler)
|
||||
# Quiet the depencency loggers
|
||||
logging.getLogger('websockets.server').setLevel(logging.WARN)
|
||||
logging.getLogger('websockets.client').setLevel(logging.WARN)
|
||||
logging.getLogger('asyncio').setLevel(logging.INFO)
|
||||
logging.getLogger('urllib3.connectionpool').setLevel(logging.INFO)
|
||||
logging.getLogger('simpleobsws').setLevel(logging.INFO)
|
||||
logging.getLogger('quart.serving').setLevel(logging.WARN)
|
||||
logging.getLogger('numba').setLevel(logging.WARN)
|
||||
logging.getLogger('hypercorn.error').setLevel(logging.WARN)
|
||||
logging.getLogger('hypercorn.access').setLevel(logging.WARN)
|
||||
# Quiet warnings
|
||||
if loglevel > logging.DEBUG:
|
||||
warnings.filterwarnings("ignore")
|
|
@ -0,0 +1,29 @@
|
|||
import logging
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
|
||||
from ovtk_audiencekit.core import MainProcess
|
||||
from .group import cli
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument('config_file', type=click.Path('r'), default='config.kdl')
|
||||
@click.option('--bus-bind', default='localhost')
|
||||
@click.option('--bus-port', default='8080')
|
||||
@click.option('--web-bind', default='localhost')
|
||||
@click.option('--web-port', default='8000')
|
||||
def start(config_file, bus_bind=None, bus_port=None, web_bind=None, web_port=None):
|
||||
"""Start audiencekit server"""
|
||||
logger.info('Hewwo!!')
|
||||
main = MainProcess(config_file,
|
||||
bus_conf=(bus_bind, bus_port),
|
||||
web_conf=(web_bind, web_port))
|
||||
try:
|
||||
asyncio.run(main.run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
logger.info('Suya~')
|
|
@ -0,0 +1,126 @@
|
|||
import importlib
|
||||
import dataclasses
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
import click
|
||||
import websockets
|
||||
|
||||
from ovtk_audiencekit.events import Event
|
||||
from ovtk_audiencekit.utils import make_sync, get_subclasses
|
||||
from ovtk_audiencekit.events.Subscription import User
|
||||
|
||||
from .group import cli
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@make_sync
|
||||
async def send(data, ws):
|
||||
async with websockets.connect(ws) as websocket:
|
||||
await websocket.send(data)
|
||||
|
||||
|
||||
@click.pass_context
|
||||
def mkevent_generic(ctx, *args, **kwargs):
|
||||
mockevent = ctx.obj['event'](ctx.obj['via'], *args, **kwargs)
|
||||
send(mockevent.serialize(), ctx.obj['ws'])
|
||||
|
||||
|
||||
class EnumChoice(click.Choice):
|
||||
def __init__(self, field):
|
||||
self.enum = field.type
|
||||
super().__init__([member.name for member in self.enum], case_sensitive=False)
|
||||
|
||||
def convert(self, *args):
|
||||
result = super().convert(*args)
|
||||
enum = self.enum.__members__[result]
|
||||
return enum
|
||||
|
||||
|
||||
typemap = {
|
||||
Enum: EnumChoice,
|
||||
str: lambda _: click.STRING,
|
||||
float: lambda _: click.FLOAT,
|
||||
bool: lambda _: click.BOOL,
|
||||
int: lambda _: click.INT,
|
||||
}
|
||||
|
||||
|
||||
class EventCommandFactory(click.MultiCommand):
|
||||
def list_commands(self, ctx):
|
||||
event_classes = get_subclasses(Event)
|
||||
return [cls.__name__ for cls in event_classes if cls._hidden is not True]
|
||||
|
||||
def get_command(self, ctx, name):
|
||||
target_event = next((cls for cls in get_subclasses(Event) if cls.__name__ == name), None)
|
||||
|
||||
ctx.obj['event'] = target_event
|
||||
|
||||
params = []
|
||||
for field in dataclasses.fields(target_event):
|
||||
# Skip over Event base fields
|
||||
if field.name in ['id', 'via', 'raw', 'timestamp']:
|
||||
continue
|
||||
# Make argument from args, and options from kwargs
|
||||
required = all(isinstance(default, dataclasses.MISSING.__class__) for default in [field.default, field.default_factory])
|
||||
param_type = next((click_type(field) for key, click_type in typemap.items() if issubclass(field.type, key)), None)
|
||||
if required:
|
||||
if param_type is None:
|
||||
raise NotImplementedError(f'Dataclass type -> click CLI type not yet implimented for arg {field.name}')
|
||||
param = click.Argument([field.name], type=param_type)
|
||||
else:
|
||||
if param_type is None:
|
||||
logger.debug(f'Not implimented: {field.type} at {field.name}')
|
||||
continue
|
||||
param = click.Option(param_decls=[f'--{field.name}'], type=param_type, default=field.default, show_default=True)
|
||||
params.append(param)
|
||||
|
||||
if target_event.__dict__.get('__cli__'):
|
||||
params.extend(target_event.__cli__())
|
||||
|
||||
command = click.Command(name, callback=mkevent_generic, params=params, help=target_event.__doc__)
|
||||
return command
|
||||
|
||||
|
||||
@cli.group(name='ws')
|
||||
@click.option('--target', '-t', default='ws://localhost:8080', help="Websocket bus of the running server")
|
||||
@click.option('--chatmod', '-c', multiple=True, help="Load a chat module (makes its specific events accessible)")
|
||||
@click.option('--pluginmod', '-p', multiple=True, help="Load a plugin module (makes its specific events accessible)")
|
||||
@click.pass_context
|
||||
def websocketutils(ctx, target, chatmod=[], pluginmod=[]):
|
||||
"""Send events to a running server"""
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj['ws'] = target
|
||||
|
||||
try:
|
||||
for module_name in chatmod:
|
||||
importlib.import_module(f'.{module_name}', package='ovtk_audiencekit.chats')
|
||||
|
||||
for module_name in pluginmod:
|
||||
importlib.import_module(f'.{module_name}', package='ovtk_audiencekit.plugins')
|
||||
except ModuleNotFoundError as e:
|
||||
option = 'chatmod' if e.name.startswith('chat') else 'pluginmod'
|
||||
raise click.BadOptionUsage(option, f'Could not import requested module: {e.name}', ctx=ctx)
|
||||
|
||||
|
||||
@websocketutils.command(cls=EventCommandFactory)
|
||||
@click.option('--via', default="console")
|
||||
@click.option('--id')
|
||||
@click.option('--raw')
|
||||
@click.option('--timestamp')
|
||||
@click.pass_context
|
||||
def mkevent(ctx, via, id, raw, timestamp):
|
||||
"""Create event via CLI"""
|
||||
ctx.obj['via'] = via
|
||||
ctx.obj['id'] = id
|
||||
ctx.obj['raw'] = raw
|
||||
ctx.obj['timestamp'] = timestamp
|
||||
|
||||
|
||||
@websocketutils.command()
|
||||
@click.argument('data_json')
|
||||
@click.pass_context
|
||||
def raw(ctx, data_json):
|
||||
"""Create event via JSON"""
|
||||
send(data_json, ctx.obj['ws'])
|
|
@ -0,0 +1,154 @@
|
|||
import os
|
||||
import sys
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
import numpy as np
|
||||
import pyaudio as pya
|
||||
import librosa
|
||||
import pytsmod as tsm
|
||||
import soundfile
|
||||
from aioprocessing import AioEvent
|
||||
|
||||
# HACK: Redirect stderr to /dev/null to silence portaudio boot
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
old_stderr = os.dup(2)
|
||||
sys.stderr.flush()
|
||||
os.dup2(devnull, 2)
|
||||
os.close(devnull)
|
||||
|
||||
pyaudio = pya.PyAudio()
|
||||
|
||||
# HACK: Put stderr back where it was (i think)
|
||||
os.dup2(old_stderr, 2)
|
||||
os.close(old_stderr)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_rate(index, channels, rate):
|
||||
try:
|
||||
return pyaudio.is_format_supported(rate,
|
||||
output_channels=channels,
|
||||
output_device=index,
|
||||
output_format=pya.paFloat32)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
alt_rates = [44100, 48000]
|
||||
class Clip:
|
||||
def __init__(self, path, output_index, buffer_length=2048, speed=1, force_stereo=True):
|
||||
_raw, native_rate = librosa.load(path, sr=None, dtype='float32', mono=False)
|
||||
self._channels = _raw.shape[0] if len(_raw.shape) == 2 else 1
|
||||
if force_stereo and self._channels == 1:
|
||||
_raw = np.resize(_raw, (2,*_raw.shape))
|
||||
self._channels = 2
|
||||
|
||||
target_samplerate = native_rate
|
||||
if not check_rate(output_index, self._channels , native_rate):
|
||||
try:
|
||||
target_samplerate = next((rate for rate in alt_rates if check_rate(output_index, self._channels , rate)))
|
||||
except StopIteration:
|
||||
logger.warn('Target audio device does not claim to support any sample rates! Attempting playback at native rate')
|
||||
self._samplerate = target_samplerate
|
||||
|
||||
if native_rate != self._samplerate:
|
||||
_raw = librosa.resample(_raw, native_rate, self._samplerate, fix=True, scale=True)
|
||||
|
||||
self._raw = np.ascontiguousarray(self._stereo_transpose(_raw), dtype='float32')
|
||||
|
||||
if speed != 1:
|
||||
self.stretch(speed)
|
||||
|
||||
self._pos = 0
|
||||
self._playing = False
|
||||
self._end_event = AioEvent()
|
||||
self._stream = pyaudio.open(
|
||||
output_device_index=output_index,
|
||||
format=pya.paFloat32,
|
||||
channels=self._channels,
|
||||
rate=self._samplerate,
|
||||
frames_per_buffer=buffer_length,
|
||||
output=True,
|
||||
stream_callback=self._read_callback,
|
||||
start=False)
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return self._raw.shape[0] / self._samplerate
|
||||
|
||||
def _stereo_transpose(self, ndata):
|
||||
return ndata if self._channels == 1 else ndata.T
|
||||
|
||||
def stretch(self, speed):
|
||||
stretched = tsm.wsola(self._stereo_transpose(self._raw), speed)
|
||||
self._raw = np.ascontiguousarray(self._stereo_transpose(stretched), dtype='float32')
|
||||
|
||||
def save(self, filename):
|
||||
soundfile.write(filename, self._stereo_transpose(self._raw), self._samplerate)
|
||||
|
||||
def _play(self):
|
||||
self._playing = True
|
||||
self._pos = 0
|
||||
|
||||
if not self._stream.is_active():
|
||||
self._stream.start_stream()
|
||||
|
||||
def play(self):
|
||||
self._end_event.clear()
|
||||
self._play()
|
||||
self._end_event.wait(timeout=self.length)
|
||||
|
||||
async def aplay(self):
|
||||
self._end_event.clear()
|
||||
self._play()
|
||||
try:
|
||||
await self._end_event.coro_wait(timeout=self.length)
|
||||
except asyncio.CancelledError:
|
||||
self._playing = False
|
||||
self._stream.stop_stream()
|
||||
|
||||
def close(self):
|
||||
self._stream.close()
|
||||
|
||||
def _read_callback(self, in_data, frame_count, time_info, status):
|
||||
if self._channels > 1:
|
||||
buffer = np.zeros((frame_count, self._channels), dtype='float32')
|
||||
else:
|
||||
buffer = np.zeros((frame_count,), dtype='float32')
|
||||
|
||||
if self._playing:
|
||||
newpos = self._pos + frame_count
|
||||
clip_chunk = self._raw[self._pos:newpos]
|
||||
self._pos = newpos
|
||||
buffer[0:clip_chunk.shape[0]] = clip_chunk
|
||||
|
||||
if self._pos >= self._raw.shape[0]:
|
||||
self._playing = False
|
||||
self._end_event.set()
|
||||
|
||||
return buffer, pya.paContinue
|
||||
|
||||
@staticmethod
|
||||
def find_output_index(output):
|
||||
if output is None:
|
||||
return None
|
||||
|
||||
if ':' in output:
|
||||
host_api_name, output_name = output.split(':', 1)
|
||||
else:
|
||||
host_api_name = output
|
||||
output_name = None
|
||||
|
||||
for i in range(pyaudio.get_host_api_count()):
|
||||
host_api_info = pyaudio.get_host_api_info_by_index(i)
|
||||
if host_api_info['name'] == host_api_name:
|
||||
if output_name is None:
|
||||
return host_api_info['defaultOutputDevice']
|
||||
|
||||
for j in range(host_api_info['deviceCount']):
|
||||
device_info = pyaudio.get_device_info_by_host_api_device_index(i, j)
|
||||
if device_info['name'] == output_name:
|
||||
return device_info['index']
|
||||
raise ValueError(f'Could not find requested output device: {output_name}')
|
||||
raise ValueError(f'Could not find requested audio API: {host_api_name}')
|
|
@ -0,0 +1,143 @@
|
|||
from functools import reduce
|
||||
from operator import getitem
|
||||
from string import Formatter
|
||||
from dataclasses import dataclass, field
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import kdl
|
||||
import kdl.types
|
||||
|
||||
from ovtk_audiencekit.utils import format_exception
|
||||
|
||||
|
||||
@dataclass
|
||||
class Dynamic(ABC):
|
||||
source: str
|
||||
parser: any
|
||||
|
||||
def to_kdl(self):
|
||||
return self.source
|
||||
|
||||
@abstractmethod
|
||||
def compute():
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.source)
|
||||
|
||||
def get(instance, key):
|
||||
if isinstance(instance, dict):
|
||||
return getitem(instance, key)
|
||||
else:
|
||||
try:
|
||||
return getattr(instance, key)
|
||||
except KeyError:
|
||||
return getitem(instance, key)
|
||||
|
||||
class Arg(Dynamic):
|
||||
class GetitemFormatter(Formatter):
|
||||
def get_field(self, field_name, args, kwargs):
|
||||
keys = field_name.split('.')
|
||||
field = reduce(get, keys, kwargs)
|
||||
return (field, keys[0])
|
||||
|
||||
def compute(self, *args, _ctx={}):
|
||||
key = self.parser.fragment[1:-1]
|
||||
try:
|
||||
if '{' in key:
|
||||
return Arg.GetitemFormatter().format(key, **_ctx).replace(r'\"', '"')
|
||||
else:
|
||||
return reduce(get, key.split('.'), _ctx)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise self.parser.error(f'Invalid arg string: {e}') from e
|
||||
except Exception as e:
|
||||
raise self.parser.error(f'Exception raised during arg inject: {format_exception(e, traceback=False)}') from e
|
||||
|
||||
|
||||
class Eval(Dynamic):
|
||||
def compute(self, *args, **kwargs):
|
||||
contents = self.parser.fragment[1:-1]
|
||||
try:
|
||||
return eval(contents, kwargs)
|
||||
except Exception as e:
|
||||
raise self.parser.error(f'Exception raised during eval: {format_exception(e, traceback=False)}') from e
|
||||
|
||||
|
||||
def csv_parser(text, parser):
|
||||
text = parser.fragment[1:-1]
|
||||
return [field.strip() for field in text.split(',')]
|
||||
|
||||
def semisv_parser(text, parser):
|
||||
text = parser.fragment[1:-1]
|
||||
return [field.strip() for field in text.split(';')]
|
||||
|
||||
customValueParsers = {
|
||||
'arg': Arg,
|
||||
't': Arg,
|
||||
'eval': Eval,
|
||||
'list': csv_parser,
|
||||
'csv': csv_parser,
|
||||
'semisv': semisv_parser,
|
||||
}
|
||||
|
||||
def compute_dynamic(kdl_node, *args, **kwargs):
|
||||
args = []
|
||||
for arg in kdl_node.args:
|
||||
if isinstance(arg, Dynamic):
|
||||
arg = arg.compute(*args, **kwargs)
|
||||
args.append(arg)
|
||||
props = {}
|
||||
for key, prop in kdl_node.props.items():
|
||||
if isinstance(prop, Dynamic):
|
||||
prop = prop.compute(*args, **kwargs)
|
||||
props[key] = prop
|
||||
|
||||
return args, props
|
||||
|
||||
@dataclass
|
||||
class KdlscriptNode(kdl.Node):
|
||||
alias: str | None = field(default=None, init=False)
|
||||
sub: str | None = field(default=None, init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
lhs, *sub = self.name.split('.')
|
||||
if len(sub) == 0:
|
||||
sub = None
|
||||
alias, *name = lhs.split(':')
|
||||
if len(name) == 0:
|
||||
name = alias
|
||||
alias = None
|
||||
elif len(name) == 1:
|
||||
name = name[0]
|
||||
else:
|
||||
raise ValueError("Invalid node name")
|
||||
|
||||
self.name = name
|
||||
self.alias = alias
|
||||
self.sub = sub
|
||||
|
||||
# HACK: Gross disgusting monkey patch
|
||||
kdl.types.Node = KdlscriptNode
|
||||
|
||||
kdl_parse_config = kdl.ParseConfig(valueConverters=customValueParsers)
|
||||
kdl_reserved = ['secrets', 'chat', 'plugin', 'import']
|
||||
|
||||
|
||||
def parse_kdl_deep(path, relativeto=None):
|
||||
if relativeto:
|
||||
path = os.path.normpath(os.path.join(relativeto, path))
|
||||
|
||||
with open(path, 'r') as f:
|
||||
try:
|
||||
config = kdl.parse(f.read(), kdl_parse_config)
|
||||
for node in config.nodes:
|
||||
node.args, node.props = compute_dynamic(node)
|
||||
except kdl.errors.ParseError as e:
|
||||
e.file = path
|
||||
raise e
|
||||
|
||||
for node in config.nodes:
|
||||
if node.name == 'import':
|
||||
yield from parse_kdl_deep(node.args[0], relativeto=os.path.dirname(path))
|
||||
else:
|
||||
yield node
|
|
@ -2,24 +2,7 @@ import os
|
|||
import random
|
||||
|
||||
import appdirs
|
||||
from kdl import ParseConfig
|
||||
|
||||
def csv_parser(text, fragment):
|
||||
text = fragment.fragment[1:-1]
|
||||
return [field.strip() for field in text.split(',')]
|
||||
|
||||
def semisv_parser(text, fragment):
|
||||
text = fragment.fragment[1:-1]
|
||||
return [field.strip() for field in text.split(';')]
|
||||
|
||||
customParsers = {
|
||||
'list': csv_parser,
|
||||
'csv': csv_parser,
|
||||
'semisv': semisv_parser,
|
||||
}
|
||||
|
||||
kdl_parse_config = ParseConfig(valueConverters=customParsers)
|
||||
kdl_reserved = ['secrets', 'chat', 'plugin', 'import']
|
||||
|
||||
CACHE_DIR = appdirs.user_cache_dir('audiencekit', 'ovtk')
|
||||
DATA_DIR = appdirs.user_data_dir('audiencekit', 'ovtk')
|
|
@ -0,0 +1,344 @@
|
|||
import importlib
|
||||
from multiprocessing import Lock
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import sys
|
||||
import signal
|
||||
|
||||
import kdl
|
||||
from click import progressbar
|
||||
from aioscheduler import TimedScheduler
|
||||
from quart import Quart
|
||||
import hypercorn
|
||||
import hypercorn.asyncio
|
||||
import hypercorn.logging
|
||||
|
||||
from ovtk_audiencekit.core import WebsocketServerProcess
|
||||
from ovtk_audiencekit.core.Config import parse_kdl_deep, kdl_reserved, compute_dynamic
|
||||
from ovtk_audiencekit.events import Event, Delete
|
||||
from ovtk_audiencekit.chats.ChatProcess import ShutdownRequest
|
||||
from ovtk_audiencekit.plugins import builtins, PluginError
|
||||
from ovtk_audiencekit.utils import format_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class HypercornLoggingShim(hypercorn.logging.Logger):
|
||||
"""Force bog-standard loggers for Hypercorn"""
|
||||
def __init__(self, config):
|
||||
self.access_logger = logging.getLogger('hypercorn.access')
|
||||
self.error_logger = logging.getLogger('hypercorn.error')
|
||||
self.access_log_format = config.access_log_format
|
||||
|
||||
def import_or_reload_mod(module_name, default_package=None, external=False):
|
||||
if external:
|
||||
package = None
|
||||
import_fragment = module_name
|
||||
else:
|
||||
package = default_package
|
||||
import_fragment = f'.{module_name}'
|
||||
fq_module = f'{package}{import_fragment}' if not external else import_fragment
|
||||
|
||||
if module := sys.modules.get(fq_module):
|
||||
for submod in [mod for name, mod in sys.modules.items() if name.startswith(fq_module)]:
|
||||
importlib.reload(submod)
|
||||
importlib.reload(module)
|
||||
else:
|
||||
module = importlib.import_module(import_fragment, package=package)
|
||||
return module
|
||||
|
||||
|
||||
class MainProcess:
|
||||
def __init__(self, config_path, bus_conf=(None, None), web_conf=(None, None)):
|
||||
self._running = False
|
||||
self.config_path = config_path
|
||||
self.bus_conf = bus_conf
|
||||
self.web_conf = web_conf
|
||||
|
||||
self.chat_processes = {}
|
||||
self.plugins = {}
|
||||
self.event_queue = asyncio.Queue()
|
||||
|
||||
# Init websocket server (event bus)
|
||||
# HACK: Must be done here to avoid shadowing its asyncio loop
|
||||
self.server_process = WebsocketServerProcess(*self.bus_conf)
|
||||
self.server_process.start()
|
||||
|
||||
# Save sys.path since some config will clobber it
|
||||
self._initial_syspath = sys.path
|
||||
|
||||
def _unload_plugin(self, plugin_name):
|
||||
plugin = self.plugins[plugin_name]
|
||||
plugin.close()
|
||||
del self.plugins[plugin_name]
|
||||
del plugin
|
||||
|
||||
def _get_event_from_pipe(self, pipe):
|
||||
event = pipe.recv()
|
||||
self.event_queue.put_nowait(event)
|
||||
|
||||
def _setup_webserver(self):
|
||||
self.webserver = Quart(__name__, static_folder=None, template_folder=None)
|
||||
listen = ':'.join(self.web_conf)
|
||||
self.webserver.config['SERVER_NAME'] = listen
|
||||
@self.webserver.context_processor
|
||||
async def update_ctx():
|
||||
return { 'EVBUS': 'ws://' + ':'.join(self.bus_conf) }
|
||||
self.webserver.jinja_options = {
|
||||
'block_start_string': '<%', 'block_end_string': '%>',
|
||||
'variable_start_string': '<<', 'variable_end_string': '>>',
|
||||
'comment_start_string': '<#', 'comment_end_string': '#>',
|
||||
}
|
||||
|
||||
async def serve_coro():
|
||||
config = hypercorn.config.Config()
|
||||
config.bind = listen
|
||||
config.use_reloader = False
|
||||
config.logger_class = HypercornLoggingShim
|
||||
try:
|
||||
await hypercorn.asyncio.serve(self.webserver, config, shutdown_trigger=self.shutdown_ev.wait)
|
||||
except Exception as e:
|
||||
logger.critical(f'Failure in web process - {e}')
|
||||
logger.debug(format_exception(e))
|
||||
raise e
|
||||
# MAGIC: As root tasks are supposed to be infinte loops, raise an exception if hypercorn shut down
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
return serve_coro()
|
||||
|
||||
async def handle_events(self):
|
||||
while True:
|
||||
event = await self.event_queue.get()
|
||||
logger.info(event)
|
||||
|
||||
if isinstance(event, Event):
|
||||
for plugin_name, plugin in list(self.plugins.items()):
|
||||
try:
|
||||
event = plugin.on_bus_event(event)
|
||||
if asyncio.iscoroutinefunction(plugin.on_bus_event):
|
||||
event = await event
|
||||
except PluginError as e:
|
||||
if e.fatal:
|
||||
logger.critical(f'Failure when processing {e.source} ({e}) - disabling...')
|
||||
else:
|
||||
logger.warning(f'Encounterd error when processing {e.source} ({e})')
|
||||
logger.debug(format_exception(e))
|
||||
if e.fatal:
|
||||
self._unload_plugin(e.source)
|
||||
except Exception as e:
|
||||
self._plugin_error
|
||||
logger.critical(f'Failure when processing {plugin_name} ({e}) - disabling...')
|
||||
logger.debug(format_exception(e))
|
||||
self._unload_plugin(plugin_name)
|
||||
if event is None:
|
||||
break
|
||||
else:
|
||||
self.server_process.message_pipe.send(event)
|
||||
logger.debug(f'Event after plugin chain - {event}')
|
||||
elif isinstance(event, Delete):
|
||||
self.server_process.message_pipe.send(event)
|
||||
else:
|
||||
logger.error(f'Unknown data in event loop - {event}')
|
||||
|
||||
async def tick_plugins(self):
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
for plugin_name, plugin in list(self.plugins.items()):
|
||||
try:
|
||||
res = plugin.tick(0.5) # Not necesarily honest!
|
||||
if asyncio.iscoroutinefunction(plugin.tick):
|
||||
await res
|
||||
except Exception as e:
|
||||
logger.critical(f'Failure during background processing for {plugin_name} ({e}) - disabling...')
|
||||
logger.debug(format_exception(e))
|
||||
self._unload_plugin(plugin_name)
|
||||
|
||||
async def user_setup(self):
|
||||
config = kdl.Document(list(parse_kdl_deep(self.config_path)))
|
||||
stdin_lock = Lock()
|
||||
# Load secrets
|
||||
secrets = {}
|
||||
if node := config.get('secrets'):
|
||||
for module in node.nodes:
|
||||
fields = secrets.get(module.name, {})
|
||||
for node in module.nodes:
|
||||
fields[node.name] = node.args[0] if len(node.args) == 1 else node.args
|
||||
secrets[module.name] = fields
|
||||
|
||||
# Dynamically import chats
|
||||
with progressbar(list(config.getAll('chat')), label="Preparing modules (chats)", item_show_func=lambda i: i and i.args[0]) as bar:
|
||||
for node in bar:
|
||||
if len(node.args) == 0:
|
||||
continue
|
||||
module_name = node.args[0]
|
||||
chat_name = node.alias or module_name
|
||||
if chat_name in self.plugins:
|
||||
raise ValueError(f'Definition "{chat_name}" already exists - rename using alias syntax: `alias:chat ...`')
|
||||
secrets_for_mod = secrets.get(module_name, {})
|
||||
try:
|
||||
chat_module = import_or_reload_mod(module_name,
|
||||
default_package='ovtk_audiencekit.chats',
|
||||
external=False)
|
||||
chat_process = chat_module.Process(stdin_lock, chat_name, **node.props, **secrets_for_mod)
|
||||
self.chat_processes[chat_name] = chat_process
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to initalize {module_name} module "{chat_name}" - {e}')
|
||||
|
||||
if len(self.chat_processes.keys()) == 0:
|
||||
logger.warning('No chats configured!')
|
||||
|
||||
# Start chat processes
|
||||
for process in self.chat_processes.values():
|
||||
process.start()
|
||||
# Bridge pipe to asyncio event loop
|
||||
pipe = process.event_pipe
|
||||
# REVIEW: This does not work on windows!!!! add_reader is not implemented
|
||||
# in a way that supports pipes on either windows loop runners
|
||||
asyncio.get_event_loop().add_reader(pipe.fileno(), lambda pipe=pipe: self._get_event_from_pipe(pipe))
|
||||
|
||||
# Load plugins
|
||||
global_ctx = {}
|
||||
## Builtins
|
||||
for node_name in builtins.__all__:
|
||||
plugin = builtins.__dict__[node_name](self.chat_processes, self.event_queue, node_name, global_ctx)
|
||||
self.plugins[node_name] = plugin
|
||||
self.webserver.register_blueprint(plugin.blueprint)
|
||||
|
||||
## Dynamic
|
||||
with progressbar(list(config.getAll('plugin')), label="Preparing modules (plugins)", item_show_func=lambda i: i and i.args[0]) as bar:
|
||||
for node in bar:
|
||||
if len(node.args) == 0:
|
||||
continue
|
||||
module_name = node.args[0]
|
||||
plugin_name = node.alias or module_name
|
||||
if plugin_name in self.plugins:
|
||||
raise ValueError(f'Definition "{plugin_name}" already exists - rename using alias syntax: `alias:plugin ...`')
|
||||
secrets_for_mod = secrets.get(module_name, {})
|
||||
try:
|
||||
plugin_module = import_or_reload_mod(module_name,
|
||||
default_package='ovtk_audiencekit.plugins',
|
||||
external=False)
|
||||
plugin = plugin_module.Plugin(self.chat_processes, self.event_queue, plugin_name, global_ctx,
|
||||
**node.props, **secrets_for_mod, _children=node.nodes)
|
||||
self.plugins[plugin_name] = plugin
|
||||
# Register UI with webserver
|
||||
self.webserver.register_blueprint(plugin.blueprint)
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to initalize {module_name} plugin "{plugin_name}" - {e}')
|
||||
|
||||
# Run plugin definitions
|
||||
with progressbar(list(config.nodes), label=f"Executing {self.config_path}") as bar:
|
||||
for node in bar:
|
||||
if node.name in kdl_reserved:
|
||||
continue
|
||||
plugin_name = node.name
|
||||
plugin_module = self.plugins.get(plugin_name)
|
||||
if plugin_module is None:
|
||||
logger.error(f'Unknown plugin: {node.name}')
|
||||
else:
|
||||
await plugin_module._call(node.sub, node.tag, *node.args, **node.props, _ctx=global_ctx, _children=node.nodes)
|
||||
|
||||
async def user_shutdown(self):
|
||||
for process_name, process in list(reversed(self.chat_processes.items())):
|
||||
pipe = process.event_pipe
|
||||
process.control_pipe.send(ShutdownRequest('root'))
|
||||
process.join(5)
|
||||
if process.exitcode is None:
|
||||
process.terminate()
|
||||
asyncio.get_event_loop().remove_reader(pipe.fileno())
|
||||
del self.chat_processes[process_name]
|
||||
for plugin_name in list(reversed(self.plugins.keys())):
|
||||
# NOTE: The plugin will likely stick around in memory for a bit after this,
|
||||
# as the webserver will still have its quart blueprint attached
|
||||
self._unload_plugin(plugin_name)
|
||||
sys.path = self._initial_syspath
|
||||
|
||||
async def run(self):
|
||||
self.shutdown_ev = asyncio.Event()
|
||||
self.reload_ev = asyncio.Event()
|
||||
loop = asyncio.get_event_loop()
|
||||
user_tasks = set()
|
||||
|
||||
try:
|
||||
# System setup
|
||||
## Bridge websocket server pipe to asyncio loop
|
||||
## REVIEW: This does not work on windows!!!! add_reader is not implemented
|
||||
## in a way that supports pipes on either windows loop runners
|
||||
ws_pipe = self.server_process.message_pipe
|
||||
loop.add_reader(ws_pipe.fileno(), lambda: self._get_event_from_pipe(ws_pipe))
|
||||
## Register stdin handler
|
||||
reader = asyncio.StreamReader()
|
||||
await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
|
||||
async def discount_repl():
|
||||
# REVIEW: Not a good UX at the moment (as new logs clobber the terminal entry)
|
||||
async for line in reader:
|
||||
line = line.strip()
|
||||
if line == b'reload':
|
||||
self.reload_ev.set()
|
||||
elif line == b'quit':
|
||||
self.shutdown_ev.set()
|
||||
self.cli_task = loop.create_task(discount_repl())
|
||||
## Scheduler for timed tasks
|
||||
self._skehdule = TimedScheduler(max_tasks=1)
|
||||
self._skehdule.start()
|
||||
## UI server
|
||||
serve_coro = self._setup_webserver()
|
||||
self.webserver_task = loop.create_task(serve_coro)
|
||||
logger.debug(f'Listening on: {":".join(self.web_conf)} (UI) and {":".join(self.bus_conf)} (event bus)')
|
||||
|
||||
# User (plugin / chat) mode (reloading allowed)
|
||||
while True:
|
||||
async with self.webserver.app_context():
|
||||
await self.user_setup()
|
||||
# Start plumbing tasks
|
||||
user_tasks.add(loop.create_task(self.tick_plugins()))
|
||||
user_tasks.add(loop.create_task(self.handle_events()))
|
||||
|
||||
logger.info(f'Ready to rumble! Press Ctrl+C to shut down')
|
||||
reload_task = loop.create_task(self.reload_ev.wait())
|
||||
done, pending = await asyncio.wait([*user_tasks, self.webserver_task, reload_task], return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
if reload_task in done:
|
||||
logger.warn('Reloading (some events may be missed!)')
|
||||
logger.debug('Teardown...')
|
||||
self.reload_ev.clear()
|
||||
# Shutdown plugins / chats
|
||||
await self.user_shutdown()
|
||||
# Stop event plumbing
|
||||
for task in user_tasks:
|
||||
task.cancel()
|
||||
user_tasks.clear()
|
||||
# HACK: Restart webserver to workaround quart's inability to remove blueprints
|
||||
# Stop
|
||||
await self.webserver.shutdown()
|
||||
self.shutdown_ev.set()
|
||||
try:
|
||||
await self.webserver_task
|
||||
except asyncio.CancelledError:
|
||||
self.shutdown_ev.clear()
|
||||
# Start
|
||||
logger.debug('Startup...')
|
||||
serve_coro = self._setup_webserver()
|
||||
self.webserver_task = loop.create_task(serve_coro)
|
||||
else:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except kdl.errors.ParseError as e:
|
||||
try:
|
||||
logger.critical(f'Invalid configuration in {e.file}: line {e.line}, character {e.col} - {e.msg}')
|
||||
except AttributeError:
|
||||
logger.critical(f'Invalid configuration - {e.msg}')
|
||||
except Exception as e:
|
||||
logger.critical(f'Failure in core process - {e}')
|
||||
logger.debug(format_exception(e))
|
||||
finally:
|
||||
logger.warn('Closing up shop...')
|
||||
for task in user_tasks:
|
||||
task.cancel()
|
||||
await self.user_shutdown()
|
||||
self.webserver_task.cancel()
|
||||
self.server_process.terminate()
|
|
@ -0,0 +1,138 @@
|
|||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
import asyncio
|
||||
import os.path
|
||||
import sys
|
||||
import copy
|
||||
|
||||
import kdl
|
||||
import quart
|
||||
|
||||
from ovtk_audiencekit.core.Config import kdl_parse_config, compute_dynamic
|
||||
|
||||
|
||||
class PluginError(Exception):
|
||||
def __init__(self, source, message, fatal=True):
|
||||
self.source = source
|
||||
self.message = message
|
||||
self.fatal = fatal
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
class OvtkBlueprint(quart.Blueprint):
|
||||
def url_for(self, endpoint, *args, **kwargs):
|
||||
"""url_for method that understands blueprint-relative names under non-request contexts"""
|
||||
if endpoint.startswith('.'):
|
||||
endpoint = self.name + endpoint
|
||||
return quart.url_for(endpoint, *args, **kwargs)
|
||||
|
||||
|
||||
class PluginBase(ABC):
|
||||
plugins = {}
|
||||
|
||||
def __init__(self, chat_processes, event_queue, name, global_ctx, _children=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.chats = chat_processes
|
||||
self._event_queue = event_queue
|
||||
self._name = name
|
||||
self._global_ctx = global_ctx
|
||||
|
||||
self.plugins[name] = self
|
||||
|
||||
self.logger = logging.getLogger(f'plugin.{self._name}')
|
||||
|
||||
# HACK: This is kinda gross, and probably wont be true for frozen modules
|
||||
plugin_dir = os.path.dirname(sys.modules[self.__class__.__module__].__file__)
|
||||
self.blueprint = OvtkBlueprint(self._name, __name__,
|
||||
url_prefix=f'/{self._name}',
|
||||
static_url_path='static',
|
||||
static_folder=os.path.join(plugin_dir, 'static'),
|
||||
template_folder=os.path.join(plugin_dir, 'templates'))
|
||||
|
||||
if _children:
|
||||
raise ValueError('Module does not accept children')
|
||||
|
||||
def __del__(self):
|
||||
if self.plugins.get(self._name) == self:
|
||||
del self.plugins[self._name]
|
||||
|
||||
async def _call(self, subroutine, tag, *args, **kwargs):
|
||||
try:
|
||||
if subroutine:
|
||||
func = self
|
||||
for accessor in subroutine:
|
||||
func = getattr(func, accessor)
|
||||
else:
|
||||
func = self.run
|
||||
res = func(*args, **kwargs)
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
res = await res
|
||||
return res
|
||||
except Exception as e:
|
||||
if isinstance(e, KeyboardInterrupt):
|
||||
raise e
|
||||
raise PluginError(self._name, str(e)) from e
|
||||
|
||||
# Base class helpers
|
||||
def broadcast(self, event):
|
||||
"""Send event to every active chat"""
|
||||
for proc in self.chats.values():
|
||||
if proc.readonly:
|
||||
continue
|
||||
proc.control_pipe.send(event)
|
||||
|
||||
async def execute_kdl(self, nodes, *py_args, _ctx={}, **py_props):
|
||||
"""
|
||||
Run other plugins as configured by the passed KDL nodes collection
|
||||
If this was done in response to an event, pass it as 'event' in _ctx!
|
||||
"""
|
||||
_ctx = copy.deepcopy({**self._global_ctx, **_ctx})
|
||||
for node in nodes:
|
||||
try:
|
||||
args, props = compute_dynamic(node, _ctx=_ctx)
|
||||
target = self.plugins.get(node.name)
|
||||
if target is None:
|
||||
self.logger.warning(f'Could not find plugin or builtin with name {node.name}')
|
||||
break
|
||||
result = await target._call(node.sub, node.tag, *args, *py_args, **props, _ctx=_ctx, **py_props, _children=node.nodes)
|
||||
if node.alias:
|
||||
_ctx[node.alias] = result
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Failed to execute defered KDL: {e}')
|
||||
break
|
||||
|
||||
|
||||
def send_to_bus(self, event):
|
||||
"""
|
||||
Send an event to the event bus
|
||||
WARNING: This will cause the event to be processed by other plugins - be careful not to cause an infinite loop!
|
||||
"""
|
||||
self._event_queue.put_nowait(event)
|
||||
|
||||
# User-defined
|
||||
def close(self):
|
||||
"""Called when plugin is about to be unloaded. Use this to safely close any resouces if needed"""
|
||||
pass
|
||||
|
||||
async def tick(self, dt):
|
||||
"""Called at least every half second - perform time-dependent updates here!"""
|
||||
pass
|
||||
|
||||
async def on_bus_event(self, event):
|
||||
"""Called for every event from the chats"""
|
||||
return event
|
||||
|
||||
async def on_control_event(self, event):
|
||||
"""
|
||||
Called for events targeting this plugin name specifically.
|
||||
This is normally used for other applications to communicate with this one over the websocket interface
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, _children=None, _ctx={}, **kwargs):
|
||||
"""
|
||||
Run plugin action, either due to a definition in the config, or due to another plugin
|
||||
"""
|
||||
pass
|
|
@ -5,13 +5,14 @@ import logging
|
|||
|
||||
import websockets
|
||||
|
||||
from events import Event
|
||||
from ovtk_audiencekit.events import Event
|
||||
from ovtk_audiencekit.utils import get_subclasses
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebsocketServerProcess(Process):
|
||||
def __init__(self, port, bind):
|
||||
def __init__(self, bind, port):
|
||||
super().__init__()
|
||||
|
||||
self._bind = bind
|
||||
|
@ -20,13 +21,7 @@ class WebsocketServerProcess(Process):
|
|||
self._pipe, self._caller_pipe = Pipe()
|
||||
self.clients = set()
|
||||
|
||||
self.update_classes()
|
||||
|
||||
def update_classes(self):
|
||||
def all_subclasses(cls):
|
||||
return set(cls.__subclasses__()).union(
|
||||
[s for c in cls.__subclasses__() for s in all_subclasses(c)])
|
||||
self._event_classes = all_subclasses(Event)
|
||||
self._event_classes = get_subclasses(Event)
|
||||
|
||||
@property
|
||||
def message_pipe(self):
|
||||
|
@ -49,7 +44,7 @@ class WebsocketServerProcess(Process):
|
|||
logger.warn('Unknown data recieved on websocket', message)
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
logger.error(f'Invalid JSON - {e}')
|
||||
except StopIteration as e:
|
||||
except StopIteration:
|
||||
logger.error(f'Unknown event type - {type}')
|
||||
except TypeError as e:
|
||||
logger.error(f'Cannot create event from data - {e}')
|
||||
|
@ -90,6 +85,7 @@ class WebsocketServerProcess(Process):
|
|||
|
||||
# Make an awaitable object that flips when the pipe's underlying file descriptor is readable
|
||||
pipe_ready = asyncio.Event()
|
||||
# REVIEW: This does not work on windows!!!!
|
||||
asyncio.get_event_loop().add_reader(self._pipe.fileno(), pipe_ready.set)
|
||||
# Make and start our infinite pipe listener task
|
||||
asyncio.get_event_loop().create_task(self.handle_pipe(pipe_ready))
|
|
@ -1,2 +1,3 @@
|
|||
from .WebsocketServerProcess import WebsocketServerProcess
|
||||
from .MainProcess import MainProcess
|
||||
from .Clip import Clip
|
|
@ -1,11 +1,12 @@
|
|||
from dataclasses import dataclass, field
|
||||
import json
|
||||
|
||||
from events import Event
|
||||
from ovtk_audiencekit.events import Event
|
||||
|
||||
|
||||
@dataclass
|
||||
class Control(Event):
|
||||
"""Generic inter-bus communication"""
|
||||
target: str
|
||||
data: dict = field(default_factory=dict)
|
||||
|
||||
|
@ -15,5 +16,5 @@ class Control(Event):
|
|||
def serialize(self):
|
||||
return json.dumps({
|
||||
'type': [cls.__name__ for cls in self.__class__.__mro__],
|
||||
'data': { **self.data, 'target': self.target },
|
||||
'data': {**self.data, 'target': self.target},
|
||||
})
|
|
@ -1,10 +1,11 @@
|
|||
from dataclasses import dataclass, asdict
|
||||
|
||||
from events import Event
|
||||
from ovtk_audiencekit.events import Event
|
||||
|
||||
|
||||
@dataclass
|
||||
class Delete(Event):
|
||||
"""Inform clients to remove a specific event"""
|
||||
target_id: int
|
||||
show_masked: bool = False
|
||||
reason: str = None
|
|
@ -6,6 +6,8 @@ from dataclasses import dataclass, field, asdict
|
|||
|
||||
@dataclass
|
||||
class Event:
|
||||
# Set to true in your subclass to disable CLI creation
|
||||
_hidden = False
|
||||
via: str
|
||||
id: int = field(default_factory=lambda: random.getrandbits(32), kw_only=True)
|
||||
timestamp: float = field(default_factory=time.time, kw_only=True)
|
|
@ -1,10 +1,11 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from events import Event
|
||||
from ovtk_audiencekit.events import Event
|
||||
|
||||
|
||||
@dataclass
|
||||
class Follow(Event):
|
||||
"""User has signed up for go-live notifications"""
|
||||
user_name: str
|
||||
user_id: str
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from events import Event
|
||||
import click
|
||||
|
||||
from ovtk_audiencekit.events import Event
|
||||
|
||||
# Ordered by most to least trusted
|
||||
class USER_TYPE(str, Enum):
|
||||
|
@ -16,6 +18,7 @@ class USER_TYPE(str, Enum):
|
|||
|
||||
@dataclass
|
||||
class Message(Event):
|
||||
"""Chat message"""
|
||||
text: str
|
||||
user_name: str
|
||||
user_id: str
|
||||
|
@ -39,11 +42,21 @@ class Message(Event):
|
|||
user_type = next((enum for enum in USER_TYPE if enum.value == user_type), None)
|
||||
return super().hydrate(user_type=user_type, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def __cli__(cls):
|
||||
def dono(ctx, param, value):
|
||||
if value:
|
||||
return [value, value]
|
||||
|
||||
return [
|
||||
click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SysMessage(Message):
|
||||
"""A Message with user_type, user_name, and user_id set automatically (SYSTEM and Event.via respectfuly)"""
|
||||
|
||||
"""Message with user_type, user_name, and user_id set automatically (SYSTEM and Event.via respectfuly)"""
|
||||
_hidden = True
|
||||
user_name: str = field(init=False)
|
||||
user_id: str = field(init=False)
|
||||
user_type: USER_TYPE = USER_TYPE.SYSTEM
|
|
@ -0,0 +1,38 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
import click
|
||||
|
||||
from ovtk_audiencekit.events import Event
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
user_name: str
|
||||
user_id: str
|
||||
|
||||
@dataclass
|
||||
class Subscription(Event):
|
||||
"""User has signed up for a re-ocurring donation"""
|
||||
user_name: str
|
||||
user_id: str
|
||||
gifted_to: list[User] = None
|
||||
tier: str = None
|
||||
resub: bool = False
|
||||
streak: int = None
|
||||
total_months: int = None
|
||||
|
||||
def __repr__(self):
|
||||
if self.gifted_to:
|
||||
recipent = ', '.join(user['user_name'] for user in self.gifted_to)
|
||||
else:
|
||||
recipent = self.user_name
|
||||
return f"Subcription from {self.user_name or 'anonymous'} to {recipent} - tier = {self.tier}"
|
||||
|
||||
@classmethod
|
||||
def __cli__(cls):
|
||||
def userfactory(ctx, param, value):
|
||||
if value:
|
||||
return [User(user_name=name, user_id=name) for name in value]
|
||||
|
||||
return [
|
||||
click.Option(['--gifted_to', '-g'], type=click.STRING, callback=userfactory, multiple=True),
|
||||
]
|
|
@ -5,3 +5,5 @@ from .Control import Control
|
|||
# REVIEW: Should follow and subscription have the a common base class?
|
||||
from .Subscription import Subscription
|
||||
from .Follow import Follow
|
||||
|
||||
__all__ = ['Event', 'Message', 'SysMessage', 'Delete', 'Control', 'Subscription', 'Follow']
|
|
@ -0,0 +1,27 @@
|
|||
import asyncio
|
||||
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.core import Clip
|
||||
|
||||
class AudioAlert(PluginBase):
|
||||
def __init__(self, *args, output=None, buffer_length=2048, cutoff_prevention_buffers=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if cutoff_prevention_buffers:
|
||||
self.logger.info('`cutoff_prevention_buffers` are depricated')
|
||||
|
||||
self.sounds = {}
|
||||
self._buffer_length = int(buffer_length)
|
||||
self._output_index = Clip.find_output_index(output)
|
||||
|
||||
def run(self, path, speed=1, immediate=True, **kwargs):
|
||||
if self.sounds.get(path) is None:
|
||||
self.sounds[path] = Clip(path,
|
||||
self._output_index,
|
||||
buffer_length=self._buffer_length,
|
||||
speed=speed)
|
||||
sound = self.sounds.get(path)
|
||||
|
||||
if immediate:
|
||||
asyncio.create_task(sound.aplay())
|
||||
else:
|
||||
sound.play()
|
|
@ -1 +1,2 @@
|
|||
from .AudioAlert import AudioAlert as Plugin
|
||||
from .AudioAlert import Clip
|
|
@ -5,12 +5,12 @@ import os
|
|||
|
||||
import maya
|
||||
from requests.exceptions import HTTPError
|
||||
from owoify.owoify import owoify, Owoness
|
||||
|
||||
from plugins import PluginBase
|
||||
from core.Config import CACHE_DIR
|
||||
from plugins.Command import Command, CommandTypes
|
||||
from events.Message import Message, SysMessage, USER_TYPE
|
||||
from owoify import owoify
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.core.Data import CACHE_DIR
|
||||
from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes
|
||||
from ovtk_audiencekit.events.Message import Message, SysMessage
|
||||
|
||||
|
||||
admition_msgs = [
|
||||
|
@ -28,6 +28,11 @@ release_msgs = [
|
|||
"{user}! Freedom awaits. Keep those pants clean ya hear?",
|
||||
]
|
||||
|
||||
owomap = {
|
||||
'owo': Owoness.Owo,
|
||||
'uwu': Owoness.Uwu,
|
||||
'uvu': Owoness.Uvu,
|
||||
}
|
||||
|
||||
class JailPlugin(PluginBase):
|
||||
def __init__(self, *args, min_level='vip', persist=True, **kwargs):
|
||||
|
@ -124,7 +129,7 @@ class JailPlugin(PluginBase):
|
|||
msg = SysMessage(self._name, str(e), replies_to=event)
|
||||
self.chats[event.via].send(msg)
|
||||
return None
|
||||
except (KeyError, ValueError) as e:
|
||||
except (KeyError, ValueError):
|
||||
msg = SysMessage(self._name, "Jail fail - is the username correct?", replies_to=event)
|
||||
self.chats[event.via].send(msg)
|
||||
return None
|
||||
|
@ -135,7 +140,8 @@ class JailPlugin(PluginBase):
|
|||
self.send_to_bus(weewoo)
|
||||
elif sentence := self.sentences.get(event.user_id):
|
||||
end_date, type, username = sentence
|
||||
if type in ['owo', 'uwu', 'uvu']:
|
||||
if type in owomap.keys():
|
||||
type = owomap[type]
|
||||
event.text = owoify(event.text, type)
|
||||
elif type == 'bean':
|
||||
event.text = ' '.join(['bean'] * len(event.text.split()))
|
|
@ -0,0 +1 @@
|
|||
from .obs import OBSWSPlugin as Plugin
|
|
@ -0,0 +1,26 @@
|
|||
import asyncio
|
||||
|
||||
import simpleobsws
|
||||
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
|
||||
|
||||
class OBSWSPlugin(PluginBase):
|
||||
def __init__(self, *args, password=None, uri='ws://localhost:4455', **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.uri = uri
|
||||
|
||||
self.obsws = simpleobsws.WebSocketClient(url=uri, password=password)
|
||||
asyncio.get_event_loop().run_until_complete(self.setup())
|
||||
|
||||
async def setup(self):
|
||||
await self.obsws.connect()
|
||||
success = await self.obsws.wait_until_identified()
|
||||
if not success:
|
||||
await self.obsws.disconnect()
|
||||
raise RuntimeError(f'Could not connect to OBS websocket at {self.uri}')
|
||||
|
||||
async def run(self, type, _children=None, _ctx={}, **kwargs):
|
||||
req = simpleobsws.Request(type, requestData=kwargs)
|
||||
res = await self.obsws.call(req)
|
||||
return res.responseData
|
|
@ -0,0 +1 @@
|
|||
from .osc import OSCPlugin as Plugin
|
|
@ -0,0 +1,17 @@
|
|||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
|
||||
|
||||
class OSCPlugin(PluginBase):
|
||||
def __init__(self, *args, ip='localhost', port=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if port is None:
|
||||
raise RuntimeError('A unique port must be specified')
|
||||
self.client = SimpleUDPClient(ip, int(port))
|
||||
|
||||
async def run(self, endpoint, *data, _children=None, _ctx={}, **kwargs):
|
||||
if len(data) == 1:
|
||||
self.client.send_message(endpoint, data[0])
|
||||
else:
|
||||
self.client.send_message(endpoint, data)
|
|
@ -3,9 +3,9 @@ import logging
|
|||
import json
|
||||
import os
|
||||
|
||||
from core.Config import CACHE_DIR
|
||||
from plugins import PluginBase
|
||||
from events import Message
|
||||
from ovtk_audiencekit.core.Data import CACHE_DIR
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import Message
|
||||
|
||||
from .Formatter import PhraseCountFormatter
|
||||
|
||||
|
@ -105,7 +105,6 @@ class PhraseCounterPlugin(PluginBase):
|
|||
|
||||
|
||||
def run(self, *args, _children=None, **kwargs):
|
||||
super().run(**kwargs)
|
||||
if len(_children) != 1:
|
||||
raise ValueError('Requires a template child')
|
||||
template = _children[0]
|
||||
|
@ -117,6 +116,5 @@ class PhraseCounterPlugin(PluginBase):
|
|||
if self.persist and os.path.exists(self._cache):
|
||||
with open(self._cache, 'r') as f:
|
||||
counts = json.load(f)
|
||||
print(counts)
|
||||
if saved_counts := counts.get(counter.output):
|
||||
counter.counts = {**counter.counts, **saved_counts}
|
|
@ -2,10 +2,10 @@ from argparse import ArgumentError
|
|||
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from plugins import PluginBase
|
||||
from plugins.Command import Command, CommandTypes
|
||||
from events.Message import Message, SysMessage, USER_TYPE
|
||||
from chats.Twitch import Process as Twitch
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes
|
||||
from ovtk_audiencekit.events.Message import Message, SysMessage, USER_TYPE
|
||||
from ovtk_audiencekit.chats.Twitch import Process as Twitch
|
||||
|
||||
|
||||
class ShoutoutPlugin(PluginBase):
|
||||
|
@ -36,7 +36,6 @@ class ShoutoutPlugin(PluginBase):
|
|||
|
||||
|
||||
def run(self, username, _ctx={}, **kwargs):
|
||||
super().run(**kwargs)
|
||||
if event := _ctx.get('event'):
|
||||
text = self.make_shoutout_msg(username, event.via)
|
||||
msg = SysMessage(self._name, text)
|
|
@ -0,0 +1,93 @@
|
|||
import uuid
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
from TTS.utils.synthesizer import Synthesizer
|
||||
from TTS.utils.manage import ModelManager
|
||||
from TTS.config import load_config
|
||||
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import Message, SysMessage
|
||||
from ovtk_audiencekit.core import Clip
|
||||
from ovtk_audiencekit.core.Data import CACHE_DIR
|
||||
|
||||
|
||||
class TextToSpeechPlugin(PluginBase):
|
||||
def __init__(self, *args, output=None, cuda=None,
|
||||
engine="tts_models/en/ljspeech/tacotron2-DDC", speaker_wav=None,
|
||||
_children=None, **kwargs):
|
||||
super().__init__(*args, _children=_children)
|
||||
|
||||
self.speaker_wav = speaker_wav
|
||||
|
||||
self._output_index = Clip.find_output_index(output)
|
||||
|
||||
conf_overrides = {k[2:]: v for k, v in kwargs.items() if k.startswith('o_')}
|
||||
|
||||
self._cache = os.path.join(CACHE_DIR, 'tts')
|
||||
os.makedirs(os.path.dirname(self._cache), exist_ok=True)
|
||||
|
||||
self.cuda = cuda
|
||||
|
||||
manager = ModelManager(output_prefix=CACHE_DIR) # HACK: coqui automatically adds 'tts' subdir
|
||||
model_path, config_path, model_item = manager.download_model(engine)
|
||||
|
||||
if model_item["default_vocoder"]:
|
||||
vocoder_path, vocoder_config_path, _ = manager.download_model(model_item["default_vocoder"])
|
||||
else:
|
||||
vocoder_path, vocoder_config_path = None, None
|
||||
|
||||
if conf_overrides:
|
||||
override_conf_path = os.path.join(self._cache, f'{self._name}_override.json')
|
||||
|
||||
config = load_config(config_path)
|
||||
for key, value in conf_overrides.items():
|
||||
config[key] = value
|
||||
config.save_json(override_conf_path)
|
||||
|
||||
config_path = override_conf_path
|
||||
|
||||
self.synthesizer = Synthesizer(
|
||||
model_path,
|
||||
config_path,
|
||||
vocoder_checkpoint=vocoder_path,
|
||||
vocoder_config=vocoder_config_path,
|
||||
use_cuda=self.cuda,
|
||||
)
|
||||
|
||||
def make_tts_wav(self, text, filename=None):
|
||||
if filename is None:
|
||||
filename = os.path.join(self._cache, f'{uuid.uuid1()}.wav')
|
||||
|
||||
if self.speaker_wav:
|
||||
wav = self.synthesizer.tts(text, None, 'en', self.speaker_wav)
|
||||
else:
|
||||
wav = self.synthesizer.tts(text)
|
||||
|
||||
self.synthesizer.save_wav(wav, filename)
|
||||
return filename
|
||||
|
||||
async def run(self, text, *args, _ctx={}, wait=True, **kwargs):
|
||||
try:
|
||||
# Force punctuation (keep AI from spinning off into random noises)
|
||||
if not any([text.endswith(punc) for punc in '.!?:']):
|
||||
text += '.'
|
||||
filename = self.make_tts_wav(text)
|
||||
# TODO: Play direct from memory
|
||||
clip = Clip(filename, self._output_index, force_stereo=False)
|
||||
if wait:
|
||||
async def play():
|
||||
await clip.aplay()
|
||||
clip.close()
|
||||
asyncio.create_task(play())
|
||||
else:
|
||||
clip.play()
|
||||
clip.close()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
if source_event := _ctx.get('event'):
|
||||
msg = SysMessage(self._name, 'Failed to make speech from input!!')
|
||||
|
||||
if isinstance(source_event, Message):
|
||||
msg.replies_to = source_event
|
||||
self.chats[source_event.via].send(msg)
|
|
@ -0,0 +1 @@
|
|||
from .TTS import TextToSpeechPlugin as Plugin
|
|
@ -0,0 +1,3 @@
|
|||
from ovtk_audiencekit.core.PluginBase import PluginBase, PluginError
|
||||
|
||||
__all__ = ['PluginBase', 'PluginError']
|
|
@ -1,21 +1,19 @@
|
|||
import subprocess
|
||||
import random
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import SysMessage
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
|
||||
|
||||
class ChancePlugin(PluginBase):
|
||||
"""Omg xd im so random"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def run(self, chance, _children=None, _ctx={}, **kwargs):
|
||||
async def run(self, chance, _children=None, _ctx={}, **kwargs):
|
||||
if isinstance(chance, str):
|
||||
chance = int(chance.replace('%', ''))
|
||||
elif not isinstance(chance, [float, int]):
|
||||
raise ValueError('Chance must be a string (optionally ending in %) or number')
|
||||
|
||||
if random.random() < chance / 100:
|
||||
for node in _children:
|
||||
self.call_plugin_from_kdl(node, _ctx=_ctx)
|
||||
await self.execute_kdl(_children, _ctx=_ctx)
|
|
@ -1,8 +1,8 @@
|
|||
import subprocess
|
||||
import random
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import SysMessage
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import SysMessage
|
||||
|
||||
|
||||
class ChoicePlugin(PluginBase):
|
||||
|
@ -10,6 +10,6 @@ class ChoicePlugin(PluginBase):
|
|||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def run(self, _children=None, _ctx={}, **kwargs):
|
||||
async def run(self, _children=None, _ctx={}, **kwargs):
|
||||
node = random.choice(_children)
|
||||
self.call_plugin_from_kdl(node, _ctx=_ctx)
|
||||
await self.execute_kdl([node], _ctx=_ctx)
|
|
@ -7,9 +7,9 @@ import sys
|
|||
|
||||
from multipledispatch import dispatch
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import Message, SysMessage
|
||||
from events.Message import USER_TYPE
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import Message, SysMessage
|
||||
from ovtk_audiencekit.events.Message import USER_TYPE
|
||||
|
||||
|
||||
USER_TYPES_BY_RANKING = list(reversed(USER_TYPE))
|
||||
|
@ -104,7 +104,6 @@ class CommandPlugin(PluginBase):
|
|||
self.commands[cmd.name] = (cmd, None, True)
|
||||
|
||||
def run(self, name, help=None, display=False, _children=None, **kwargs):
|
||||
super().run(**kwargs)
|
||||
actionnode = next((node for node in _children if node.name == 'do'), None)
|
||||
if actionnode is None:
|
||||
raise ValueError('Command defined without an action (`do` tag)')
|
||||
|
@ -120,7 +119,7 @@ class CommandPlugin(PluginBase):
|
|||
|
||||
self.commands[name] = (cmd, actionnode, display)
|
||||
|
||||
def on_bus_event(self, event):
|
||||
async def on_bus_event(self, event):
|
||||
if isinstance(event, Message):
|
||||
for command, actionnode, display in self.commands.values():
|
||||
# Defined via register_help (ie, handled by another plugin) - skip!
|
||||
|
@ -131,8 +130,7 @@ class CommandPlugin(PluginBase):
|
|||
args = command.parse(event.text)
|
||||
self.logger.debug(f"Parsed args for {command.name}: {args}")
|
||||
ctx = dict(event=event, **args)
|
||||
for node in actionnode.nodes:
|
||||
self.call_plugin_from_kdl(node, _ctx=ctx)
|
||||
await self.execute_kdl(actionnode.nodes, _ctx=ctx)
|
||||
except argparse.ArgumentError as e:
|
||||
msg = SysMessage(self._name, f"{e}. See !help {command.name}", replies_to=event)
|
||||
self.chats[event.via].send(msg)
|
|
@ -0,0 +1,122 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
import maya
|
||||
import aioscheduler
|
||||
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.utils import format_exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIME_SEGMENTS = ['seconds', 'minutes', 'hours', 'days']
|
||||
|
||||
class Cue:
|
||||
def __init__(self, repeat, at=None, **kwargs):
|
||||
self.repeat = repeat
|
||||
self.enabled = True
|
||||
|
||||
if at:
|
||||
self._next = maya.when(at)
|
||||
else:
|
||||
self._next = maya.now().add(**kwargs)
|
||||
self._interval = kwargs
|
||||
|
||||
@property
|
||||
def next(self):
|
||||
return self._next.datetime(to_timezone='UTC', naive=True)
|
||||
|
||||
def is_obsolete(self):
|
||||
if self.repeat:
|
||||
return False
|
||||
if self._next <= maya.now():
|
||||
return False
|
||||
return True
|
||||
|
||||
def reschedule(self, fresh=False):
|
||||
if fresh:
|
||||
self._next = maya.now().add(**self._interval)
|
||||
else:
|
||||
self._next = self._next.add(**self._interval)
|
||||
# HACK: Compare epochs directly, as maya comps are only second accurate
|
||||
if not fresh and self._next._epoch <= maya.now()._epoch:
|
||||
offset = maya.now()._epoch - self._next._epoch
|
||||
logger.warn(f'Cannot keep up with configured interval - {underrun} underrun. Repetition may fail!')
|
||||
self._next = maya.now().add(**self._interval)
|
||||
|
||||
|
||||
class CuePlugin(PluginBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.cues = {}
|
||||
self.tasks = {}
|
||||
|
||||
self.scheduler = aioscheduler.TimedScheduler()
|
||||
self.scheduler.start()
|
||||
|
||||
self._cleanup_task = asyncio.create_task(self._cleanup())
|
||||
|
||||
def run(self, name=None, repeat=False, enabled=None, _children=[], _ctx={}, **kwargs):
|
||||
if name is None:
|
||||
name = str(uuid.uuid4())
|
||||
|
||||
first_set = self.cues.get(name) is None
|
||||
|
||||
if len(_children) > 0:
|
||||
has_interval = any(kwargs.get(segment) is not None for segment in TIME_SEGMENTS)
|
||||
if kwargs.get('at') is None and not has_interval:
|
||||
raise ValueError('Provide a concrete time with `at` or a timer length with `seconds`, `hours`, etc')
|
||||
if kwargs.get('at') is not None and repeat and not has_interval:
|
||||
raise ValueError('`repeat` can not be used with solely a concrete time')
|
||||
|
||||
cue = Cue(repeat, **kwargs)
|
||||
|
||||
async def handler():
|
||||
# Repetion management
|
||||
if not cue.enabled:
|
||||
return
|
||||
if cue.repeat:
|
||||
cue.reschedule()
|
||||
self.tasks[name] = self.scheduler.schedule(handler(), cue.next)
|
||||
# Run configured actions
|
||||
try:
|
||||
await self.execute_kdl(_children, _ctx=_ctx)
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to complete cue {name}: {e}')
|
||||
self.logger.debug(format_exception(e))
|
||||
|
||||
self.cues[name] = (cue, handler)
|
||||
self.schedule_exec(name, cue.next, handler())
|
||||
|
||||
if enabled is not None:
|
||||
entry = self.cues.get(name)
|
||||
if entry is None:
|
||||
self.logger.warn(f'Cannot find cue with name "{name}"')
|
||||
return
|
||||
cue, handler = entry
|
||||
|
||||
cue.enabled = enabled
|
||||
|
||||
if enabled and not first_set:
|
||||
cue.reschedule(fresh=True)
|
||||
self.schedule_exec(name, cue.next, handler())
|
||||
|
||||
def schedule_exec(self, name, at, coro):
|
||||
if existing_task := self.tasks.get(name):
|
||||
self.scheduler.cancel(existing_task)
|
||||
try:
|
||||
self.tasks[name] = self.scheduler.schedule(coro, at)
|
||||
except ValueError as e:
|
||||
self.logger.error(f'Cannot schedule cue {name} at {at}: {e}')
|
||||
|
||||
async def _cleanup(self):
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
for name, (cue, _) in self.cues.items():
|
||||
if cue.is_obsolete():
|
||||
del self.cues[name]
|
||||
if task := self.tasks.get(name):
|
||||
self.scheduler.cancel(task)
|
||||
del self.tasks[name]
|
|
@ -1,7 +1,7 @@
|
|||
import subprocess
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import SysMessage
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import SysMessage
|
||||
|
||||
|
||||
class ExecPlugin(PluginBase):
|
||||
|
@ -9,7 +9,6 @@ class ExecPlugin(PluginBase):
|
|||
super().__init__(*args, **kwargs)
|
||||
self.warned = False
|
||||
|
||||
# TODO: Need to make the main loop async compatible so this can be a coro
|
||||
def run(self, cmd, reply=False, _ctx={}, **kwargs):
|
||||
if not self.warned:
|
||||
self.logger.warning('Executing unchecked input is potentially dangerous! Check your (arg) inputs, if any, *very* carefully')
|
|
@ -0,0 +1,14 @@
|
|||
from ovtk_audiencekit.plugins import PluginBase
|
||||
|
||||
import logging
|
||||
|
||||
level_names = ['critical', 'error', 'warning', 'info', 'debug']
|
||||
|
||||
class LogPlugin(PluginBase):
|
||||
def run(self, msg, level="info", **kwargs):
|
||||
try:
|
||||
int_level = next((getattr(logging, level_name.upper()) for level_name in level_names if level_name.startswith(level.lower())))
|
||||
except StopIteration:
|
||||
self.logger.debug(f'Using default log level for KDL log call since user level "{level}" is not recognized')
|
||||
int_level = logging.INFO
|
||||
self.logger.log(int_level, msg)
|
|
@ -0,0 +1,61 @@
|
|||
import asyncio
|
||||
|
||||
import mido
|
||||
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
|
||||
|
||||
def matches(msg, attrs):
|
||||
for attr, match_val in attrs.items():
|
||||
msg_val = getattr(msg, attr)
|
||||
if msg_val != match_val:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class MidiPlugin(PluginBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
loop = asyncio.get_event_loop()
|
||||
def callback(msg):
|
||||
asyncio.run_coroutine_threadsafe(self.recv_callback(msg), loop)
|
||||
|
||||
self.output_port = mido.open_output()
|
||||
self.input_port = mido.open_input(callback=callback)
|
||||
self.listeners = {
|
||||
'note_off': [],
|
||||
'note_on': [],
|
||||
'control_change': [],
|
||||
'program_change': [],
|
||||
'sysex': [],
|
||||
'song_select': [],
|
||||
'start': [],
|
||||
'continue': [],
|
||||
'stop': [],
|
||||
}
|
||||
|
||||
def close(self):
|
||||
self.input_port.close()
|
||||
self.output_port.close()
|
||||
|
||||
def run(self, type, _ctx={}, _children=None, **kwargs):
|
||||
if type == 'sysex':
|
||||
data = kwargs['data']
|
||||
msg = mido.Message('sysex', data=bytes(data, encoding='utf-8'), time=0)
|
||||
else:
|
||||
msg = mido.Message(type, **kwargs, time=0)
|
||||
self.output_port.send(msg)
|
||||
|
||||
async def recv_callback(self, msg):
|
||||
if hasattr(msg, 'channel'):
|
||||
msg.channel += 1 # Channels in mido are 0-15, but in spec are 1-16. Adjust to spec
|
||||
self.logger.debug(f"Recv: {msg}")
|
||||
for params, handler, ctx in self.listeners[msg.type]:
|
||||
if matches(msg, params):
|
||||
_ctx = {**ctx, 'midi': msg}
|
||||
await handler(_ctx)
|
||||
|
||||
def listen(self, type, _ctx={}, _children=None, **kwargs):
|
||||
kwargs = {k:int(v) for k, v in kwargs.items()}
|
||||
handler = lambda ctx: self.execute_kdl(_children, _ctx=ctx)
|
||||
self.listeners[type].append((kwargs, handler, _ctx))
|
|
@ -1,5 +1,5 @@
|
|||
from plugins import PluginBase
|
||||
from events import SysMessage, Message
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import SysMessage, Message
|
||||
|
||||
|
||||
class ReplyPlugin(PluginBase):
|
|
@ -0,0 +1,150 @@
|
|||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
from typing import Callable
|
||||
import quart
|
||||
import json
|
||||
|
||||
import kdl
|
||||
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.utils import format_exception
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scene:
|
||||
name: str
|
||||
group: str
|
||||
enter: Callable
|
||||
exit: Callable
|
||||
entry_context: dict = field(default_factory=dict)
|
||||
tasks: list[asyncio.Task] = field(default_factory=list)
|
||||
|
||||
|
||||
class ScenePlugin(PluginBase):
|
||||
"""Allows for creating modal configurations"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.scenes = {}
|
||||
self.active = {}
|
||||
|
||||
self._scene_state_changed = asyncio.Event()
|
||||
|
||||
self.blueprint.add_url_rule('/', 'ctrlpanel', self.ui_ctrlpanel)
|
||||
self.blueprint.add_url_rule('/<name>/<cmd>', 'api-sceneset', self.ui_setscene)
|
||||
self.blueprint.add_url_rule('/monitor', 'monitor', self.ui_monitor_ws, is_websocket=True)
|
||||
|
||||
async def run(self, name, _children=None, _ctx={}, active=None, group=None, immediate=True, **kwargs):
|
||||
if _children is None and active is None:
|
||||
raise UsageError('Either define a new scene or set `--active` to true / false')
|
||||
|
||||
if _children:
|
||||
await self.define(name, group, _children, default_active=active, ctx=_ctx)
|
||||
else:
|
||||
await self.switch(name, active, is_immediate=immediate, ctx=_ctx)
|
||||
|
||||
async def define(self, name, group, children, default_active=False, ctx={}):
|
||||
if self.scenes.get(name) is not None:
|
||||
raise UsageError(f'Scene with name "{name}" already exists!')
|
||||
|
||||
# Categorize nodes
|
||||
enter_nodes = []
|
||||
exit_nodes = []
|
||||
for child in children:
|
||||
if child.name == 'exit':
|
||||
exit_nodes.extend(child.nodes)
|
||||
else:
|
||||
enter_nodes.append(child)
|
||||
# Make transisition functions
|
||||
async def enter(ctx):
|
||||
await self.execute_kdl(enter_nodes, _ctx=ctx)
|
||||
scene.entry_context = ctx
|
||||
async def exit(ctx):
|
||||
await self.execute_kdl(exit_nodes, _ctx=ctx)
|
||||
|
||||
scene = Scene(name, group, enter, exit)
|
||||
self.scenes[name] = scene
|
||||
|
||||
if default_active:
|
||||
await self.switch(name, default_active, is_immediate=True, ctx=ctx)
|
||||
|
||||
async def switch(self, name, active, is_immediate=True, ctx={}):
|
||||
scene = self.scenes.get(name)
|
||||
if scene is None:
|
||||
raise UsageError(f'No defined scene with name "{name}"')
|
||||
|
||||
if active:
|
||||
if current := self.active.get(scene.group):
|
||||
if current == scene:
|
||||
return
|
||||
await self._execute(current, 'exit', is_immediate, ctx)
|
||||
self.active[scene.group] = scene
|
||||
await self._execute(scene, 'enter', is_immediate, ctx)
|
||||
else:
|
||||
if self.active.get(scene.group) == scene:
|
||||
self.active[scene.group] = None
|
||||
await self._execute(scene, 'exit', is_immediate, ctx)
|
||||
|
||||
self._scene_state_changed.set()
|
||||
self._scene_state_changed.clear()
|
||||
|
||||
async def _execute(self, scene, mode, immediate, ctx):
|
||||
ctx = {**ctx} # HACK: Copy to avoid leakage from previous group item exit
|
||||
|
||||
scene_transision_fn = getattr(scene, mode)
|
||||
|
||||
# Wrap to handle context at exec time
|
||||
async def context_wrapper(ctx):
|
||||
if mode == 'exit':
|
||||
ctx = {
|
||||
**scene.entry_context,
|
||||
'caller_ctx': ctx,
|
||||
}
|
||||
await scene_transision_fn(ctx)
|
||||
if mode == 'enter':
|
||||
scene.entry_context = ctx
|
||||
coro = context_wrapper(ctx)
|
||||
# Wrap to finish any other pending tasks before running this
|
||||
if len(scene.tasks):
|
||||
async def exec_order_wrap(other):
|
||||
await asyncio.gather(*scene.tasks)
|
||||
scene.tasks = []
|
||||
await other
|
||||
coro = exec_order_wrap(coro)
|
||||
|
||||
# Run (or schedule for execution)
|
||||
if immediate:
|
||||
try:
|
||||
await coro
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to handle "{scene.name}" {mode} transistion: {e}')
|
||||
self.logger.debug(format_exception(e))
|
||||
else:
|
||||
scene.tasks.append(asyncio.create_task(coro))
|
||||
|
||||
def _get_state(self):
|
||||
groups = {}
|
||||
for scene_name, scene in self.scenes.items():
|
||||
active = self.active.get(scene.group) == scene
|
||||
group = scene.group or "default group"
|
||||
if groups.get(group) is None:
|
||||
groups[group] = {}
|
||||
groups[group][scene_name] = active
|
||||
return groups
|
||||
|
||||
async def ui_ctrlpanel(self):
|
||||
groups = self._get_state()
|
||||
return await quart.render_template('index.html', init_state=json.dumps(groups))
|
||||
|
||||
async def ui_setscene(self, name=None, cmd=None):
|
||||
active = cmd == 'activate'
|
||||
await self.switch(name, active, is_immediate=True)
|
||||
return quart.Response(status=200)
|
||||
|
||||
async def ui_monitor_ws(self):
|
||||
await quart.websocket.accept()
|
||||
while True:
|
||||
groups = self._get_state()
|
||||
await quart.websocket.send(json.dumps(groups))
|
||||
await self._scene_state_changed.wait()
|
|
@ -0,0 +1 @@
|
|||
from .Plugin import ScenePlugin, Scene
|
|
@ -0,0 +1,83 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test page</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" }
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import { createApp, ref, onMounted } from 'vue'
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const groups = ref(JSON.parse('<< init_state|safe >>'))
|
||||
const inflight = ref([])
|
||||
onMounted(() => {
|
||||
const websock = new WebSocket('<<url_for(".monitor")>>');
|
||||
websock.addEventListener('message', (msg) => {
|
||||
groups.value = JSON.parse(msg.data)
|
||||
inflight.value = []
|
||||
})
|
||||
})
|
||||
const toggle = async (group_name, scene_name) => {
|
||||
if (inflight.value.includes(scene_name)) return
|
||||
inflight.value.push(scene_name)
|
||||
const next_state = !groups.value[group_name][scene_name]
|
||||
await fetch(`${scene_name}/${next_state ? 'activate' : 'deactivate'}`, { method: 'GET' })
|
||||
}
|
||||
return { groups, inflight, toggle }
|
||||
},
|
||||
}).mount('#app')
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body id="root">
|
||||
<div id="app">
|
||||
<div v-for="(group, group_name) in groups" class="group">
|
||||
<h3>{{ group_name }}</h3>
|
||||
<div v-for="(active, scene_name) in group" v-on:click="toggle(group_name, scene_name)"
|
||||
:class="{ active, pending: inflight.includes(scene_name), scene: true }"
|
||||
>
|
||||
<p>{{ scene_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style type="text/css">
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
font-family: sans-serif;
|
||||
gap: 8px;
|
||||
}
|
||||
p {
|
||||
text-align: center;
|
||||
}
|
||||
h3 {
|
||||
margin-right: 1em;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.scene {
|
||||
padding: 12px 24px;
|
||||
user-select: none;
|
||||
background-color: lightgray;
|
||||
flex: 1;
|
||||
}
|
||||
.scene.pending {
|
||||
background-color: lightgoldenrodyellow;
|
||||
}
|
||||
.scene.active {
|
||||
background-color: lightgreen;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,40 @@
|
|||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.core.Config import compute_dynamic
|
||||
|
||||
|
||||
class SetPlugin(PluginBase):
|
||||
"""Set arbitrary data in the local context (can be fetched with the custom arg type)"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def run(self, *args, _children=[], _ctx={}, **kwargs):
|
||||
self.proc_node(_ctx, *args, _children=_children, _stack=[], **kwargs)
|
||||
self.logger.debug(_ctx)
|
||||
|
||||
def proc_node(self, target, *args, _children=[], _stack=[], **props):
|
||||
if len(args) > 0 and len(props) > 0:
|
||||
raise ValueError("Cannot use both item/list and keyword forms at the same time")
|
||||
if _children and (len(args) > 0 or len(props) > 0):
|
||||
raise ValueError("Cannot define value as something and dict at the same time")
|
||||
if len(args) > 0 and len(_stack) == 0:
|
||||
raise ValueError("Cannot use item/list short form on top level set")
|
||||
|
||||
if len(props) > 0:
|
||||
for key, value in props.items():
|
||||
if target.get(key) is not None:
|
||||
fullkey = '.'.join([n for n, t in _stack] + [key])
|
||||
self.logger.debug(f'Shadowing {fullkey}')
|
||||
target[key] = value
|
||||
elif _children:
|
||||
for child in _children:
|
||||
sub = dict()
|
||||
target[child.name] = sub
|
||||
stack = [*_stack, (child.name, target)]
|
||||
key = '.'.join(s for s, t in stack)
|
||||
|
||||
args, props = compute_dynamic(child, _ctx=stack[0][1])
|
||||
self.proc_node(sub, *args, _children=child.nodes, _stack=stack, **props)
|
||||
elif len(args) > 0:
|
||||
name, target = _stack[-1]
|
||||
target[name] = args[0] if len(args) == 1 else args
|
|
@ -1,9 +1,9 @@
|
|||
import re
|
||||
from dataclasses import dataclass, asdict
|
||||
from dataclasses import dataclass
|
||||
import typing
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import Message
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import Message
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -36,7 +36,7 @@ class Trigger:
|
|||
self.source = [source.lower() for source in self.source]
|
||||
|
||||
if self.monitization:
|
||||
self.monitization_exact = '-' in self.monitization
|
||||
self.monitization_exact = not '-' in self.monitization
|
||||
self.monitization = list(float(bound) if bound != '' else None for bound in self.monitization.split('-'))
|
||||
|
||||
def matches(self, event, last_msg=None):
|
||||
|
@ -46,7 +46,7 @@ class Trigger:
|
|||
or (all(cls.__name__ != self.event for cls in event.__class__.__mro__)):
|
||||
return False
|
||||
if self.monitization:
|
||||
if self.monitization_exact:
|
||||
if not self.monitization_exact:
|
||||
lower_bound = self.monitization[0]
|
||||
upper_bound = self.monitization[1] if len(self.monitization) == 2 else None
|
||||
if not (event.monitization is not None
|
||||
|
@ -77,11 +77,12 @@ class Trigger:
|
|||
for key, value in self.attr_checks.items():
|
||||
try:
|
||||
event_value = event.to_dict()[key]
|
||||
if event_value != value or (str(event_value) != str(value)):
|
||||
return False
|
||||
except KeyError:
|
||||
return False
|
||||
if event_value != value:
|
||||
return False
|
||||
except AttributeError:
|
||||
if value is not None:
|
||||
return False
|
||||
except (AttributeError, TypeError):
|
||||
# HACK: lazy bird's event type checking
|
||||
return False
|
||||
|
||||
|
@ -107,15 +108,15 @@ class TriggerPlugin(PluginBase):
|
|||
unknown_args[key] = value
|
||||
trigger = Trigger(**args, attr_checks=unknown_args)
|
||||
|
||||
actions = [lambda event, ctx=_ctx, node=node: self.call_plugin_from_kdl(node, _ctx={**ctx, 'event': event}) for node in _children]
|
||||
handler = lambda ctx: self.execute_kdl(_children, _ctx=ctx)
|
||||
|
||||
self.triggers.append((trigger, actions))
|
||||
self.triggers.append((trigger, handler, _ctx))
|
||||
|
||||
def on_bus_event(self, event):
|
||||
for trigger, actions in self.triggers:
|
||||
async def on_bus_event(self, event):
|
||||
for trigger, handler, ctx in self.triggers:
|
||||
if trigger.matches(event, self.last_msg):
|
||||
for action in actions:
|
||||
action(event)
|
||||
_ctx = {**ctx, 'event': event}
|
||||
await handler(_ctx)
|
||||
if isinstance(event, Message):
|
||||
self.last_msg = event
|
||||
return event
|
|
@ -2,9 +2,9 @@ import subprocess
|
|||
|
||||
import websockets
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import SysMessage
|
||||
from utils import make_sync
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import SysMessage
|
||||
from ovtk_audiencekit.utils import make_sync
|
||||
|
||||
@make_sync
|
||||
async def send(ws, data):
|
||||
|
@ -15,6 +15,5 @@ class WebSocketPlugin(PluginBase):
|
|||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# TODO: Need to make the main loop async compatible so this can be a coro
|
||||
def run(self, endpoint, data, _ctx={}, **kwargs):
|
||||
send(endpoint, data)
|
|
@ -1,7 +1,7 @@
|
|||
import os
|
||||
|
||||
from plugins import PluginBase
|
||||
from events import SysMessage, Message
|
||||
from ovtk_audiencekit.plugins import PluginBase
|
||||
from ovtk_audiencekit.events import SysMessage, Message
|
||||
|
||||
|
||||
class WritePlugin(PluginBase):
|
||||
|
@ -13,6 +13,6 @@ class WritePlugin(PluginBase):
|
|||
base_name = os.path.dirname(text)
|
||||
if base_name:
|
||||
os.makedirs(base_name, exist_ok=True)
|
||||
|
||||
|
||||
with open(target, 'a' if append else 'w') as f:
|
||||
f.write(text)
|
|
@ -0,0 +1,15 @@
|
|||
from .Trigger import TriggerPlugin as trigger
|
||||
from .Reply import ReplyPlugin as reply
|
||||
from .Command import CommandPlugin as command
|
||||
from .Cue import CuePlugin as cue
|
||||
from .Write import WritePlugin as write
|
||||
from .Exec import ExecPlugin as exec
|
||||
from .Chance import ChancePlugin as chance
|
||||
from .Choice import ChoicePlugin as choice
|
||||
from .Midi import MidiPlugin as midi
|
||||
from .WebSocket import WebSocketPlugin as ws
|
||||
from .Set import SetPlugin as set
|
||||
from .Scene import ScenePlugin as scene
|
||||
from .Log import LogPlugin as log
|
||||
|
||||
__all__ = ['trigger', 'reply', 'command', 'cue', 'write', 'exec', 'chance', 'choice', 'midi', 'ws', 'set', 'scene', 'log']
|
|
@ -1,5 +1,4 @@
|
|||
from multiprocessing import Process, Pipe
|
||||
from traceback import format_exception
|
||||
import logging
|
||||
import asyncio
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from .NonBlockingWebsocket import NonBlockingWebsocket
|
||||
from .make_sync import make_sync
|
||||
from .get_subclasses import get_subclasses
|
||||
from .format_exception import format_exception
|
|
@ -0,0 +1,4 @@
|
|||
import traceback as traceback_lib
|
||||
|
||||
def format_exception(e, traceback=True):
|
||||
return ''.join(traceback_lib.format_exception(None, e, e.__traceback__ if traceback else None))
|
|
@ -0,0 +1,4 @@
|
|||
def get_subclasses(cls):
|
||||
"""Return a set of all subclasses (recursively) of a given class"""
|
||||
return set(cls.__subclasses__()).union(
|
||||
[s for c in cls.__subclasses__() for s in get_subclasses(c)])
|
|
@ -1,2 +0,0 @@
|
|||
from .NonBlockingWebsocket import NonBlockingWebsocket
|
||||
from .make_sync import make_sync
|
Loading…
Reference in New Issue