Compare commits

..

2 commits

Author SHA1 Message Date
Derek Schmidt
a0487033af [Cli] Add docstrings and allow hiding of event subclasses from mkevent 2022-03-31 01:06:26 -04:00
Derek Schmidt
56f57f22b6 [Cli] Better event creation! 2022-03-30 21:28:31 -04:00
98 changed files with 1666 additions and 4329 deletions

7
.gitignore vendored
View file

@ -1,7 +1,4 @@
.venv/
.pdm-python
.pdm-build/
__pycache__/
/dist/
*.secret* *.secret*
secrets.kdl secrets.kdl
__pycache__/
/*-log

30
Pipfile Normal file
View file

@ -0,0 +1,30 @@
[[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 = "*"

771
Pipfile.lock generated Normal file
View file

@ -0,0 +1,771 @@
{
"_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"
}
}
}

View file

@ -1,84 +1,41 @@
### Notice # Open Vtuber Tool Kit - Audience Interaction module
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. 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.
# About audiencekit It consists of three parts:
`audiencekit` is a streamer-centric automation tool and bot platform: 1. Chat modules
+ Pulls events (chat messages, donations, etc) from various livestreaming platforms, normalizing them into generalized types 2. A websocket server
+ Provides a rich system for reacting to (or mutating) those events 3. A powerful, easy to use plugin system
+ Exposes the end-result as a simple websocket event steam
Part of (but usable independent from) the [Open Vtuber ToolKit][ovtk]
## For (Power) Users ## Chat Modules
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. 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).
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: Each runs in its own process, and so can be written with little concern for the rest of the application to ease development requirements.
+ 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.
For example, a simple raid automation looks like: ## 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:
```kdl ```kdl
trigger event="Raid" { trigger event="Raid" {
reply "Thank you very much for the raid!" reply (arg)"Thank you very much for the {event.user_count} raid, {event.from_channel}!"
} }
``` ```
## For developers 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):
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 ```python
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import Message from events import Message
class Plugin(PluginBase): class Plugin(PluginBase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_bus_event(self, event): def on_bus_event(self, event):
if isinstance(event, Message): if isinstance(event, Message):
event.text.replace('simp', 'shrimp') event.text.replace('simp', 'shrimp')
return event 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

View file

@ -1,7 +0,0 @@
#!/usr/bin/env python3
import sys
sys.path.insert(0, 'src')
from ovtk_audiencekit.cli import cli
if __name__ == '__main__':
cli()

View file

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from multiprocessing import Process, Pipe, Manager from multiprocessing import Process, Pipe, Manager
from traceback import format_exception
import sys import sys
import os import os
import json import json
import logging import logging
from ovtk_audiencekit.events import Event from events import Event
from ovtk_audiencekit.utils import format_exception
class GracefulShutdownException(Exception): class GracefulShutdownException(Exception):
@ -133,7 +133,7 @@ class ChatProcess(Process, ABC):
return 0 return 0
except Exception as e: except Exception as e:
self.logger.error(f'Uncaught exception in {self._name}: {e}') self.logger.error(f'Uncaught exception in {self._name}: {e}')
self.logger.debug(format_exception(e)) self.logger.debug(''.join(format_exception(None, e, e.__traceback__)))
return 1 return 1
finally: finally:
self.on_exit() self.on_exit()

View file

@ -1,9 +1,9 @@
import random import random
from enum import Enum, auto from enum import Enum, auto
from ovtk_audiencekit.chats import ChatProcess from chats import ChatProcess
from ovtk_audiencekit.events import Event, Message, SysMessage from events import Event, Message, SysMessage
from ovtk_audiencekit.events.Message import USER_TYPE from events.Message import USER_TYPE
class STATES(Enum): class STATES(Enum):

View file

@ -4,9 +4,9 @@ import logging
from enum import Enum, auto from enum import Enum, auto
from itertools import chain from itertools import chain
from ovtk_audiencekit.chats import ChatProcess from chats import ChatProcess
from ovtk_audiencekit.utils import NonBlockingWebsocket from utils import NonBlockingWebsocket
from ovtk_audiencekit.events.Message import Message, USER_TYPE from events.Message import Message, USER_TYPE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,13 +29,13 @@ class MisskeyProcess(ChatProcess):
self.state = STATES.CONNECTING self.state = STATES.CONNECTING
def normalize_event(self, event): def normalize_event(self, event):
user_name = event['user']['name'] or event['user']['username'] user_name = event['user']['name']
user_id = event['user']['id'] user_id = event['user']['id']
text = event.get('text', '') text = event.get('text', '')
attachments = [(file['type'], file['url']) for file in event.get('files', [])] 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', []))} emojis = {emoji['name']: emoji['url'] for emoji in chain(event.get('emojis', []), event['user'].get('emojis', []))}
if text or attachments: if text or attachments:
msg = Message(self._name, text or '', msg = Message(self._name, text,
user_name, user_id, USER_TYPE.USER, user_name, user_id, USER_TYPE.USER,
id=event['id'], emotes=emojis or None, id=event['id'], emotes=emojis or None,
attachments=attachments or None) attachments=attachments or None)

View file

@ -1,6 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from ovtk_audiencekit.events import Event from events import Event
# TODO: Include list of raider usernames (for hate raid moderation) # TODO: Include list of raider usernames (for hate raid moderation)
@dataclass @dataclass

View file

@ -2,8 +2,8 @@ import time
from enum import Enum, auto from enum import Enum, auto
from itertools import chain from itertools import chain
from ovtk_audiencekit.chats import ChatProcess from chats import ChatProcess
from ovtk_audiencekit.events import Message, SysMessage from events import Message, SysMessage
from .sources.TwitchIRC import TwitchIRC, TwitchIRCException from .sources.TwitchIRC import TwitchIRC, TwitchIRCException
from .sources.TwitchAPI import TwitchAPI from .sources.TwitchAPI import TwitchAPI
@ -26,9 +26,7 @@ class TwitchProcess(ChatProcess):
# IRC options # IRC options
botname=None, emote_res=4.0, botname=None, emote_res=4.0,
# EventSub options # EventSub options
eventsub=True, eventsub_host='wss://ovtk.skeh.site/twitch', eventsub_host='wss://ovtk.skeh.site/twitch',
# BTTV integration
bttv=False,
# Inheritance boilerplate # Inheritance boilerplate
**kwargs): **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -55,16 +53,9 @@ class TwitchProcess(ChatProcess):
self.shared.target_data = target_data self.shared.target_data = target_data
self.shared.users = [] self.shared.users = []
self._sources = []
self.irc = TwitchIRC(self._channel_name, self._username, self._token, cheermotes, emote_res, self.shared) self.irc = TwitchIRC(self._channel_name, self._username, self._token, cheermotes, emote_res, self.shared)
self._sources.append(self.irc) self.eventsub = TwitchEventSub(self.api, eventsub_host)
if eventsub: self.bttv = BTTV(target_data['user']['id'])
self.eventsub = TwitchEventSub(self.api, eventsub_host)
self._sources.append(self.eventsub)
self.bttv = BTTV(target_data['user']['id']) if bttv else None
def loop(self, next_state): def loop(self, next_state):
return self._state_machine(self.state, next_state) return self._state_machine(self.state, next_state)
@ -91,16 +82,15 @@ class TwitchProcess(ChatProcess):
def on_connecting(self, next_state): def on_connecting(self, next_state):
self.irc.connect() self.irc.connect()
if self.__dict__.get('eventsub'): self.eventsub.subscribe(self._channel_name)
self.eventsub.subscribe(self._channel_name)
return STATES.READING return STATES.READING
def on_reading(self, next_state): def on_reading(self, next_state):
try: try:
for event in chain(*(source.read(0.1) for source in self._sources)): for event in chain(self.irc.read(0.1), self.eventsub.read(0.1)):
# Retarget event # Retarget event
event.via = self._name event.via = self._name
if self.bttv and isinstance(event, Message): if isinstance(event, Message):
event = self.bttv.hydrate(event) event = self.bttv.hydrate(event)
self.publish(event) self.publish(event)
return 0 return 0

View file

@ -1,11 +1,10 @@
import json import json
import logging import logging
from ovtk_audiencekit.utils import NonBlockingWebsocket from utils import NonBlockingWebsocket
from ovtk_audiencekit.core.Data import ovtk_user_id
from ovtk_audiencekit.events import Follow
from ..Events import ChannelPointRedemption from ..Events import ChannelPointRedemption
from core.Config import ovtk_user_id
from events import Follow
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -5,12 +5,11 @@ from itertools import chain, islice
import logging import logging
from collections import OrderedDict from collections import OrderedDict
import websockets.exceptions
from miniirc import ircv3_message_parser from miniirc import ircv3_message_parser
from ovtk_audiencekit.events.Message import Message, USER_TYPE from events.Message import Message, USER_TYPE
from ovtk_audiencekit.events import Subscription from events import Subscription
from ovtk_audiencekit.utils import NonBlockingWebsocket from utils import NonBlockingWebsocket
from ..Events import Raid from ..Events import Raid
@ -41,7 +40,6 @@ class TwitchIRC:
self.users = shared.users self.users = shared.users
self._reply_buffer_maxlen = 1000 self._reply_buffer_maxlen = 1000
self._reply_buffer = OrderedDict() self._reply_buffer = OrderedDict()
self._group_gifts = {}
def connect(self): def connect(self):
self._ws = NonBlockingWebsocket(WEBSOCKET_ADDRESS) self._ws = NonBlockingWebsocket(WEBSOCKET_ADDRESS)
@ -58,37 +56,33 @@ class TwitchIRC:
raise TwitchIRCException(f'Got bad response during auth: {response}') raise TwitchIRCException(f'Got bad response during auth: {response}')
def read(self, timeout): def read(self, timeout):
try: if self._ws.poll(timeout):
if self._ws.poll(timeout): messages = self._ws.recv()
messages = self._ws.recv() for message in messages.splitlines():
for message in messages.splitlines(): normalized = None
normalized = None cmd, hostmask, tags, args = ircv3_message_parser(message)
cmd, hostmask, tags, args = ircv3_message_parser(message) logger.debug(f'cmd: {cmd}, hostmask: {hostmask}, args: {args}, tags: {tags}')
logger.debug(f'cmd: {cmd}, hostmask: {hostmask}, args: {args}, tags: {tags}') if cmd == 'PRIVMSG':
if cmd == 'PRIVMSG': normalized = self.normalize_message(hostmask, tags, args)
normalized = self.normalize_message(hostmask, tags, args) elif cmd == 'USERNOTICE':
elif cmd == 'USERNOTICE': normalized = self.normalize_event(hostmask, tags, args)
normalized = self.normalize_event(hostmask, tags, args) elif cmd == 'PING':
elif cmd == 'PING': self._ws.send(f"PONG {' '.join(args)}")
self._ws.send(f"PONG {' '.join(args)}") elif cmd == 'RECONNECT':
elif cmd == 'RECONNECT': raise TimeoutError('Twitch API requested timeout')
raise TimeoutError('Twitch API requested timeout') elif cmd == 'JOIN':
elif cmd == 'JOIN': self.users.append(hostmask[0])
self.users.append(hostmask[0]) elif cmd == 'PART':
elif cmd == 'PART': try:
try: self.users.remove(hostmask[0])
self.users.remove(hostmask[0]) except ValueError:
except ValueError: pass
pass
if normalized: if normalized:
self._reply_buffer[normalized.id] = normalized self._reply_buffer[normalized.id] = normalized
if len(self._reply_buffer.keys()) > self._reply_buffer_maxlen: if len(self._reply_buffer.keys()) > self._reply_buffer_maxlen:
self._reply_buffer.popitem() self._reply_buffer.popitem()
yield normalized yield normalized
except websockets.exceptions.ConnectionClosedError:
self.logger.info('Twitch websocket disconnected - trying reconnet')
self.connect()
def send(self, message): def send(self, message):
irc_msg = f'PRIVMSG #{self._username} :{message}' irc_msg = f'PRIVMSG #{self._username} :{message}'

View file

@ -5,8 +5,8 @@ from enum import Enum, auto
import requests import requests
from ovtk_audiencekit.chats import ChatProcess from chats import ChatProcess
from ovtk_audiencekit.events.Message import Message, SysMessage, USER_TYPE from events.Message import Message, SysMessage, USER_TYPE
class STATES(Enum): class STATES(Enum):

View file

@ -1,3 +1 @@
from .ChatProcess import ChatProcess from .ChatProcess import ChatProcess
__all__ = ['ChatProcess']

View file

@ -1,5 +1,3 @@
from .group import cli from .group import cli
from .start import start from .start import start
from .websocketutils import websocketutils from .websocketutils import websocketutils
__all__ = ['cli', 'start', 'websocketutils']

5
cli/group.py Normal file
View file

@ -0,0 +1,5 @@
import click
@click.group()
def cli():
pass

88
cli/start.py Normal file
View file

@ -0,0 +1,88 @@
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):
"""Start audiencekit server"""
# 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()

View file

@ -1,56 +1,29 @@
import json
import importlib import importlib
import dataclasses import dataclasses
from enum import Enum
import logging
import click import click
import websockets 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 from .group import cli
from events import Event
logger = logging.getLogger(__name__) from utils import make_sync, get_subclasses
@make_sync @make_sync
async def send(data, ws): async def send(data, ws):
async with websockets.connect(ws) as websocket: async with websockets.connect(ws) as websocket:
await websocket.send(data) await websocket.send(data)
@click.pass_context @click.pass_context
def mkevent_generic(ctx, *args, **kwargs): def mkevent_generic(ctx, *args, **kwargs):
mockevent = ctx.obj['event'](ctx.obj['via'], *args, **kwargs) mockevent = ctx.obj['event'](ctx.obj['via'], *args, **kwargs)
send(mockevent.serialize(), ctx.obj['ws']) 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): class EventCommandFactory(click.MultiCommand):
def list_commands(self, ctx): def list_commands(self, ctx):
event_classes = get_subclasses(Event) event_classes = get_subclasses(Event)
return [cls.__name__ for cls in event_classes if cls._hidden is not True] return [cls.__name__ for cls in event_classes if cls._hidden != True]
def get_command(self, ctx, name): def get_command(self, ctx, name):
target_event = next((cls for cls in get_subclasses(Event) if cls.__name__ == name), None) target_event = next((cls for cls in get_subclasses(Event) if cls.__name__ == name), None)
@ -63,24 +36,14 @@ class EventCommandFactory(click.MultiCommand):
if field.name in ['id', 'via', 'raw', 'timestamp']: if field.name in ['id', 'via', 'raw', 'timestamp']:
continue continue
# Make argument from args, and options from kwargs # Make argument from args, and options from kwargs
required = all(isinstance(default, dataclasses.MISSING.__class__) for default in [field.default, field.default_factory]) required = all(isinstance(default, dataclasses._MISSING_TYPE) 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 required:
if param_type is None: param = click.Argument([field.name])
raise NotImplementedError(f'Dataclass type -> click CLI type not yet implimented for arg {field.name}')
param = click.Argument([field.name], type=param_type)
else: else:
if param_type is None: param = click.Option(param_decls=[f'--{field.name}'])
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) params.append(param)
command = click.Command(name, callback=mkevent_generic, params=params, help=target_event.__doc__) command = click.Command(name, callback=mkevent_generic, params=params, help=target_event.__doc__)
if target_event.__dict__.get('_cli'):
command = target_event._cli(command)
return command return command
@ -96,15 +59,14 @@ def websocketutils(ctx, target, chatmod=[], pluginmod=[]):
try: try:
for module_name in chatmod: for module_name in chatmod:
importlib.import_module(f'.{module_name}', package='ovtk_audiencekit.chats') importlib.import_module(f'.{module_name}', package='chats')
for module_name in pluginmod: for module_name in pluginmod:
importlib.import_module(f'.{module_name}', package='ovtk_audiencekit.plugins') importlib.import_module(f'.{module_name}', package='plugins')
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
option = 'chatmod' if e.name.startswith('chat') else 'pluginmod' option = 'chatmod' if e.name.startswith('chat') else 'pluginmod'
raise click.BadOptionUsage(option, f'Could not import requested module: {e.name}', ctx=ctx) raise click.BadOptionUsage(option, f'Could not import requested module: {e.name}', ctx=ctx)
@websocketutils.command(cls=EventCommandFactory) @websocketutils.command(cls=EventCommandFactory)
@click.option('--via', default="console") @click.option('--via', default="console")
@click.option('--id') @click.option('--id')
@ -118,7 +80,6 @@ def mkevent(ctx, via, id, raw, timestamp):
ctx.obj['raw'] = raw ctx.obj['raw'] = raw
ctx.obj['timestamp'] = timestamp ctx.obj['timestamp'] = timestamp
@websocketutils.command() @websocketutils.command()
@click.argument('data_json') @click.argument('data_json')
@click.pass_context @click.pass_context

View file

@ -1,127 +1,34 @@
/* Comments surrounded by asterisks and slashes (like this one) are instructions, /* Load config from another file at any point by doing:
comments staring with two slashes are valid example configuration! */ import "filename.kdl"
*/
/* Step 1: Give it the rights (to party) */
secrets { secrets {
/* Generate credentials via https://ovtk.skeh.site/twitch/auth /* Some features require authorization! Its recommended to place these in a
and paste them between the curly braces below */ 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 */
Twitch { Twitch {
} }
/* Generate a Youtube API key and download it as a json file. Place it somewhere safe! /* For YouTube, you need to download a client-secrets.json file and place it
Then, uncomment below, filling in the path to your file. 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 */
See https://seo-michael.co.uk/how-to-create-your-own-youtube-api-key-id-and-secret */
YoutubeLive { YoutubeLive {
// client_secrets_path r"C:\path\To\Client\Secrets\File.json" // 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 what you need /* Uncomment for YoutubeLive */
// chat "YoutubeLive"
There are two types of modules, chats and plugins: /* Silly testing chat (useful for testing scroll / bursts in a chat display)
+ Chats are self-explanitory: the event providers - livestream services. max_messages_per_chunk: The upper bound of messages to create per burst
+ Plugins are the heart of the system, and can both monitor livestream events max_delay: The upper bound of how long to wait between chunks */
and be called on directly to perform actions. // chat "FakeChat" max_messages_per_chunk=3 max_delay=10
Some plugins (called "builtins") are always available, but others are not loaded
until you ask. 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 desired name and node name with a colon (:), otherwise they take on the
module name and throw an error when two are used at the same time.
*/
// chat "Twitch" channel_name="MyTwitchChannel"
// guest:chat "Twitch" channel_name="CollabChannel" readonly=true
// aplay:plugin "AudioAlert" output="ALSA:default"
/* Step 3: Get silly with it
Some example automations are provided below to get you started.
Config is always valid KDL (https://kdl.dev/), but has some special quirks.
See the wiki for more details, but the jist is:
+ A plugin's main routine is run by making a node with its name
+ Subroutines can be run by seperating with a dot: `name.routine`
+ Returned values can be saved to the "context" (see below) by using a colon: `val:name`
+ Config is evaluated top to bottom, outter-most runs by default
+ Plugins are free to parse their children (inside braces) however they like, so not all nodes are the same! See `command` and `scene`.
+ Custom type `t` (for template) can be used to insert data from the context, builtin `set` can be used to write to it.
+ Plugins often use this to share additional data!
*/
/* Self-promo every 2 hours */
// cue 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 (t)"!so {event.from_channel}"
// }
/* Lurk command (!lurk) */
// 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-" {
// TTS (t)"{event.user_name} says: {event.text}"
// }
/* Control OBS from your midi controller - see https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requests */
// plugin "OBS" password="A Very Secret Example Password"
// plugin "Midi"
// Midi.listen "control_change" channel=1 control=1 value=127 {
// OBS "SetInputMute" inputName="Microphone" inputMuted=true
// }
// Midi.listen "control_change" channel=1 control=1 value=0 {
// OBS "SetInputMute" inputName="Microphone" inputMuted=false
// }
/* Talk to your midi rig too! */
// trigger event="ChannelPointRedemption" action="Sound Sussy" {
// Midi "program_change" channel=1 program=1
// cue minutes=5 {
// Midi "program_change" channel=1 program=0
// }
// }
/* Control just about anything else, from anywhere (http://localhost:8000/scene/) else! */
// plugin "OSC" port=9000
// plugin "OBS" password="A Very Secret Example Password"
// scene "Brb / starting" {
// OBS "SetCurrentProgramScene" sceneName="Starting"
// OSC "/track/1/mute" (u8)1
//
// exit {
// OSC "/track/1/mute" (u8)0
// }
// }
// scene "Live" {
// OBS "SetCurrentProgramScene" sceneName="Live"
// }
/* Step 4: Organize
Split config into multiple files to keep sane, or even define entirely seperate
setups for special occasions and run by providing their path to the `start` command.
*/
// import "base.kdl"
// import "secrets_i_promise_not_to_open_on_air.kdl"
/* Step 5: Reach for the stars!
Still can't make that stupid idea a reality? Custom plugins are a single file in "src/ovtk_audiencekit/plugins" away~
Learn a little Python, hack on any of the other plugins,
toss PluginBase.py at ChatGPT and try your luck,
or nicely ask your local birdy <3
*/

View file

@ -2,7 +2,24 @@ import os
import random import random
import appdirs 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') CACHE_DIR = appdirs.user_cache_dir('audiencekit', 'ovtk')
DATA_DIR = appdirs.user_data_dir('audiencekit', 'ovtk') DATA_DIR = appdirs.user_data_dir('audiencekit', 'ovtk')

215
core/MainProcess.py Normal file
View file

@ -0,0 +1,215 @@
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()

View file

@ -5,14 +5,14 @@ import logging
import websockets import websockets
from ovtk_audiencekit.events import Event from events import Event
from ovtk_audiencekit.utils import get_subclasses from utils import get_subclasses
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class WebsocketServerProcess(Process): class WebsocketServerProcess(Process):
def __init__(self, bind, port): def __init__(self, port, bind):
super().__init__() super().__init__()
self._bind = bind self._bind = bind
@ -44,7 +44,7 @@ class WebsocketServerProcess(Process):
logger.warn('Unknown data recieved on websocket', message) logger.warn('Unknown data recieved on websocket', message)
except json.decoder.JSONDecodeError as e: except json.decoder.JSONDecodeError as e:
logger.error(f'Invalid JSON - {e}') logger.error(f'Invalid JSON - {e}')
except StopIteration: except StopIteration as e:
logger.error(f'Unknown event type - {type}') logger.error(f'Unknown event type - {type}')
except TypeError as e: except TypeError as e:
logger.error(f'Cannot create event from data - {e}') logger.error(f'Cannot create event from data - {e}')
@ -85,7 +85,6 @@ class WebsocketServerProcess(Process):
# Make an awaitable object that flips when the pipe's underlying file descriptor is readable # Make an awaitable object that flips when the pipe's underlying file descriptor is readable
pipe_ready = asyncio.Event() pipe_ready = asyncio.Event()
# REVIEW: This does not work on windows!!!!
asyncio.get_event_loop().add_reader(self._pipe.fileno(), pipe_ready.set) asyncio.get_event_loop().add_reader(self._pipe.fileno(), pipe_ready.set)
# Make and start our infinite pipe listener task # Make and start our infinite pipe listener task
asyncio.get_event_loop().create_task(self.handle_pipe(pipe_ready)) asyncio.get_event_loop().create_task(self.handle_pipe(pipe_ready))

View file

@ -1,4 +1,2 @@
from .WebsocketServerProcess import WebsocketServerProcess from .WebsocketServerProcess import WebsocketServerProcess
from .Plugins import PluginBase, PluginError
from .MainProcess import MainProcess from .MainProcess import MainProcess
from .Audio import Clip, Stream

20
events/Control.py Normal file
View file

@ -0,0 +1,20 @@
from dataclasses import dataclass, field
import json
from events import Event
@dataclass
class Control(Event):
"""Generic inter-bus communication"""
target: str
data: dict = field(default_factory=dict)
def __repr__(self):
return f"Control message : target = {self.target}, data = {self.data}"
def serialize(self):
return json.dumps({
'type': [cls.__name__ for cls in self.__class__.__mro__],
'data': { **self.data, 'target': self.target },
})

View file

@ -1,6 +1,6 @@
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from ovtk_audiencekit.events import Event from events import Event
@dataclass @dataclass

View file

@ -1,6 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from ovtk_audiencekit.events import Event from events import Event
@dataclass @dataclass

View file

@ -1,9 +1,7 @@
from enum import Enum from enum import Enum
from dataclasses import dataclass, field from dataclasses import dataclass, field
import click from events import Event
from ovtk_audiencekit.events import Event
# Ordered by most to least trusted # Ordered by most to least trusted
class USER_TYPE(str, Enum): class USER_TYPE(str, Enum):
@ -42,15 +40,6 @@ class Message(Event):
user_type = next((enum for enum in USER_TYPE if enum.value == user_type), None) user_type = next((enum for enum in USER_TYPE if enum.value == user_type), None)
return super().hydrate(user_type=user_type, **kwargs) return super().hydrate(user_type=user_type, **kwargs)
@classmethod
def _cli(cls, cmd):
def dono(ctx, param, value):
if value:
return [value, value]
cmd.params.append(click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono))
return cmd
@dataclass @dataclass
class SysMessage(Message): class SysMessage(Message):

View file

@ -1,20 +1,14 @@
from dataclasses import dataclass from dataclasses import dataclass
import click from events import Event
from ovtk_audiencekit.events import Event
@dataclass
class User:
user_name: str
user_id: str
@dataclass @dataclass
class Subscription(Event): class Subscription(Event):
"""User has signed up for a re-ocurring donation""" """User has signed up for a re-ocurring donation"""
user_name: str user_name: str
user_id: str user_id: str
gifted_to: list[User] = None gifted_to: list = None
tier: str = None tier: str = None
resub: bool = False resub: bool = False
streak: int = None streak: int = None
@ -26,14 +20,3 @@ class Subscription(Event):
else: else:
recipent = self.user_name recipent = self.user_name
return f"Subcription from {self.user_name or 'anonymous'} to {recipent} - tier = {self.tier}" return f"Subcription from {self.user_name or 'anonymous'} to {recipent} - tier = {self.tier}"
@classmethod
def _cli(cls, cmd):
def userfactory(ctx, param, value):
if value:
return [User(user_name=name, user_id=name) for name in value]
cmd.params.append(
click.Option(['--gifted_to', '-g'], type=click.STRING, callback=userfactory, multiple=True)
)
return cmd

View file

@ -5,6 +5,3 @@ from .Control import Control
# REVIEW: Should follow and subscription have the a common base class? # REVIEW: Should follow and subscription have the a common base class?
from .Subscription import Subscription from .Subscription import Subscription
from .Follow import Follow from .Follow import Follow
__all__ = ['Event', 'Message', 'SysMessage', 'Delete', 'Control', 'Subscription', 'Follow']
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

4
main.py Normal file
View file

@ -0,0 +1,4 @@
from cli import cli
if __name__ == '__main__':
cli()

1832
pdm.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,97 @@
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)

View file

@ -1,2 +1 @@
from .AudioAlert import AudioAlert as Plugin from .AudioAlert import AudioAlert as Plugin
from .AudioAlert import Clip

View file

@ -1,22 +1,21 @@
import subprocess
import random import random
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from events import SysMessage
class ChancePlugin(PluginBase): class ChancePlugin(PluginBase):
"""Omg xd im so random""" """Omg xd im so random"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
async def run(self, chance, _children=None, _ctx={}, **kwargs): def run(self, chance, _children=None, _ctx={}, **kwargs):
if isinstance(chance, str): if isinstance(chance, str):
chance = int(chance.replace('%', '')) chance = int(chance.replace('%', ''))
elif not isinstance(chance, [float, int]): elif not isinstance(chance, [float, int]):
raise ValueError('Chance must be a string (optionally ending in %) or number') raise ValueError('Chance must be a string (optionally ending in %) or number')
if random.random() < chance / 100: if random.random() < chance / 100:
await self.execute_kdl([child for child in _children if child.name != 'or'], _ctx=_ctx) for node in _children:
else: self.call_plugin_from_kdl(node, _ctx=_ctx)
if elsenode := next((child for child in _children if child.name == 'or'), None):
await self.execute_kdl(elsenode.nodes, _ctx=_ctx)

View file

@ -1,8 +1,8 @@
import subprocess import subprocess
import random import random
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import SysMessage from events import SysMessage
class ChoicePlugin(PluginBase): class ChoicePlugin(PluginBase):
@ -10,6 +10,6 @@ class ChoicePlugin(PluginBase):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
async def run(self, _children=None, _ctx={}, **kwargs): def run(self, _children=None, _ctx={}, **kwargs):
node = random.choice(_children) node = random.choice(_children)
await self.execute_kdl([node], _ctx=_ctx) self.call_plugin_from_kdl(node, _ctx=_ctx)

View file

@ -7,9 +7,9 @@ import sys
from multipledispatch import dispatch from multipledispatch import dispatch
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import Message, SysMessage from events import Message, SysMessage
from ovtk_audiencekit.events.Message import USER_TYPE from events.Message import USER_TYPE
USER_TYPES_BY_RANKING = list(reversed(USER_TYPE)) USER_TYPES_BY_RANKING = list(reversed(USER_TYPE))
@ -87,7 +87,7 @@ class Command:
args = emoteless.split()[1:] args = emoteless.split()[1:]
parsed, unknown = self._parser.parse_known_args(args) parsed, unknown = self._parser.parse_known_args(args)
parsed_asdict = vars(parsed) parsed_asdict = vars(parsed)
return parsed_asdict, unknown return parsed_asdict
class CommandPlugin(PluginBase): class CommandPlugin(PluginBase):
@ -104,6 +104,7 @@ class CommandPlugin(PluginBase):
self.commands[cmd.name] = (cmd, None, True) self.commands[cmd.name] = (cmd, None, True)
def run(self, name, help=None, display=False, _children=None, **kwargs): 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) actionnode = next((node for node in _children if node.name == 'do'), None)
if actionnode is None: if actionnode is None:
raise ValueError('Command defined without an action (`do` tag)') raise ValueError('Command defined without an action (`do` tag)')
@ -119,7 +120,7 @@ class CommandPlugin(PluginBase):
self.commands[name] = (cmd, actionnode, display) self.commands[name] = (cmd, actionnode, display)
async def on_bus_event(self, event): def on_bus_event(self, event):
if isinstance(event, Message): if isinstance(event, Message):
for command, actionnode, display in self.commands.values(): for command, actionnode, display in self.commands.values():
# Defined via register_help (ie, handled by another plugin) - skip! # Defined via register_help (ie, handled by another plugin) - skip!
@ -127,11 +128,11 @@ class CommandPlugin(PluginBase):
continue continue
if command.invoked(event): if command.invoked(event):
try: try:
args, unknown = command.parse(event.text) args = command.parse(event.text)
self.logger.debug(f"Parsed args for {command.name}: {args}") self.logger.debug(f"Parsed args for {command.name}: {args}")
self.logger.debug(f"Remaining text: {unknown}") ctx = dict(event=event, **args)
ctx = dict(event=event, rest=' '.join(unknown), **args) for node in actionnode.nodes:
await self.execute_kdl(actionnode.nodes, _ctx=ctx) self.call_plugin_from_kdl(node, _ctx=ctx)
except argparse.ArgumentError as e: except argparse.ArgumentError as e:
msg = SysMessage(self._name, f"{e}. See !help {command.name}", replies_to=event) msg = SysMessage(self._name, f"{e}. See !help {command.name}", replies_to=event)
self.chats[event.via].send(msg) self.chats[event.via].send(msg)
@ -139,7 +140,7 @@ class CommandPlugin(PluginBase):
if self.help_cmd.invoked(event): if self.help_cmd.invoked(event):
try: try:
args, _ = self.help_cmd.parse(event.text) args = self.help_cmd.parse(event.text)
except argparse.ArgumentError as e: except argparse.ArgumentError as e:
msg = SysMessage(self._name, f"{e}. See !help {self.help_cmd.name}", replies_to=event) msg = SysMessage(self._name, f"{e}. See !help {self.help_cmd.name}", replies_to=event)
self.chats[event.via].send(msg) self.chats[event.via].send(msg)

56
plugins/Cue.py Normal file
View file

@ -0,0 +1,56 @@
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]

View file

@ -1,7 +1,7 @@
import subprocess import subprocess
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import SysMessage from events import SysMessage
class ExecPlugin(PluginBase): class ExecPlugin(PluginBase):
@ -9,7 +9,8 @@ class ExecPlugin(PluginBase):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.warned = False self.warned = False
def run(self, cmd, reply=False, to_arg=None, _ctx={}, **kwargs): # 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: if not self.warned:
self.logger.warning('Executing unchecked input is potentially dangerous! Check your (arg) inputs, if any, *very* carefully') self.logger.warning('Executing unchecked input is potentially dangerous! Check your (arg) inputs, if any, *very* carefully')
self.warned = True self.warned = True
@ -17,13 +18,11 @@ class ExecPlugin(PluginBase):
out = subprocess.run(cmd.split(' '), capture_output=True, text=True) out = subprocess.run(cmd.split(' '), capture_output=True, text=True)
if out.returncode != 0: if out.returncode != 0:
self.logger.warning(f'Command returned {out.returncode}: {out.stderr}') self.logger.error(f'Command retruned {out.returncode}: {out.stderr}')
return return
else: else:
self.logger.debug(f'Command returned {out.returncode}: {out.stdout}') self.logger.info(f'Command retruned {out.returncode}: {out.stdout}')
if reply and _ctx['event']: if reply and _ctx['event']:
msg = SysMessage(self._name, out.stdout) msg = SysMessage(self._name, out.stdout)
self.chats[_ctx['event'].via].send(msg) self.chats[_ctx['event'].via].send(msg)
if to_arg:
_ctx[to_arg] = out.stdout

View file

@ -5,12 +5,12 @@ import os
import maya import maya
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from owoify.owoify import owoify, Owoness
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.core.Data import CACHE_DIR from core.Config import CACHE_DIR
from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes from plugins.Command import Command, CommandTypes
from ovtk_audiencekit.events.Message import Message, SysMessage from events.Message import Message, SysMessage, USER_TYPE
from owoify import owoify
admition_msgs = [ admition_msgs = [
@ -28,14 +28,10 @@ release_msgs = [
"{user}! Freedom awaits. Keep those pants clean ya hear?", "{user}! Freedom awaits. Keep those pants clean ya hear?",
] ]
owomap = {
'owo': Owoness.Owo,
'uwu': Owoness.Uwu,
'uvu': Owoness.Uvu,
}
class JailPlugin(PluginBase): class JailPlugin(PluginBase):
def setup(self, min_level='vip', persist=True): def __init__(self, *args, min_level='vip', persist=True, **kwargs):
super().__init__(*args, **kwargs)
self.persist = persist self.persist = persist
self._cache = os.path.join(CACHE_DIR, 'Jail', 'sentences') self._cache = os.path.join(CACHE_DIR, 'Jail', 'sentences')
os.makedirs(os.path.dirname(self._cache), exist_ok=True) os.makedirs(os.path.dirname(self._cache), exist_ok=True)
@ -87,7 +83,7 @@ class JailPlugin(PluginBase):
if isinstance(event, Message): if isinstance(event, Message):
if self.jail_command.invoked(event): if self.jail_command.invoked(event):
try: try:
args, _ = self.jail_command.parse(event.text) args = self.jail_command.parse(event.text)
end_date = maya.when(args['length'], prefer_dates_from='future') end_date = maya.when(args['length'], prefer_dates_from='future')
deets = self.chats[event.via].shared.api.get_user_details(args['username']) deets = self.chats[event.via].shared.api.get_user_details(args['username'])
if deets is None: if deets is None:
@ -117,7 +113,7 @@ class JailPlugin(PluginBase):
self.send_to_bus(weewoo) self.send_to_bus(weewoo)
elif self.unjail_command.invoked(event): elif self.unjail_command.invoked(event):
try: try:
args, _ = self.jail_command.parse(event.text) args = self.jail_command.parse(event.text)
deets = self.chats[event.via].shared.api.get_user_details(args['username']) deets = self.chats[event.via].shared.api.get_user_details(args['username'])
if deets is None: if deets is None:
raise ValueError() raise ValueError()
@ -128,7 +124,7 @@ class JailPlugin(PluginBase):
msg = SysMessage(self._name, str(e), replies_to=event) msg = SysMessage(self._name, str(e), replies_to=event)
self.chats[event.via].send(msg) self.chats[event.via].send(msg)
return None return None
except (KeyError, ValueError): except (KeyError, ValueError) as e:
msg = SysMessage(self._name, "Jail fail - is the username correct?", replies_to=event) msg = SysMessage(self._name, "Jail fail - is the username correct?", replies_to=event)
self.chats[event.via].send(msg) self.chats[event.via].send(msg)
return None return None
@ -139,8 +135,7 @@ class JailPlugin(PluginBase):
self.send_to_bus(weewoo) self.send_to_bus(weewoo)
elif sentence := self.sentences.get(event.user_id): elif sentence := self.sentences.get(event.user_id):
end_date, type, username = sentence end_date, type, username = sentence
if type in owomap.keys(): if type in ['owo', 'uwu', 'uvu']:
type = owomap[type]
event.text = owoify(event.text, type) event.text = owoify(event.text, type)
elif type == 'bean': elif type == 'bean':
event.text = ' '.join(['bean'] * len(event.text.split())) event.text = ' '.join(['bean'] * len(event.text.split()))

20
plugins/Midi.py Normal file
View file

@ -0,0 +1,20 @@
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')

View file

@ -3,9 +3,9 @@ import logging
import json import json
import os import os
from ovtk_audiencekit.core.Data import CACHE_DIR from core.Config import CACHE_DIR
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import Message from events import Message
from .Formatter import PhraseCountFormatter from .Formatter import PhraseCountFormatter
@ -64,7 +64,8 @@ class PhraseCounter:
class PhraseCounterPlugin(PluginBase): class PhraseCounterPlugin(PluginBase):
def setup(self, debounce_time=1, persist=False): def __init__(self, *args, debounce_time=1, persist=False, **kwargs):
super().__init__(*args, **kwargs)
self.debounce_time = debounce_time self.debounce_time = debounce_time
self.persist = persist self.persist = persist
@ -103,7 +104,8 @@ class PhraseCounterPlugin(PluginBase):
return event return event
def run(self, *args, _children=None, _ctx={}, **kwargs): def run(self, *args, _children=None, **kwargs):
super().run(**kwargs)
if len(_children) != 1: if len(_children) != 1:
raise ValueError('Requires a template child') raise ValueError('Requires a template child')
template = _children[0] template = _children[0]
@ -115,5 +117,6 @@ class PhraseCounterPlugin(PluginBase):
if self.persist and os.path.exists(self._cache): if self.persist and os.path.exists(self._cache):
with open(self._cache, 'r') as f: with open(self._cache, 'r') as f:
counts = json.load(f) counts = json.load(f)
print(counts)
if saved_counts := counts.get(counter.output): if saved_counts := counts.get(counter.output):
counter.counts = {**counter.counts, **saved_counts} counter.counts = {**counter.counts, **saved_counts}

118
plugins/PluginBase.py Normal file
View file

@ -0,0 +1,118 @@
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

View file

@ -1,5 +1,5 @@
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import SysMessage, Message from events import SysMessage, Message
class ReplyPlugin(PluginBase): class ReplyPlugin(PluginBase):

View file

@ -2,15 +2,17 @@ from argparse import ArgumentError
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes from plugins.Command import Command, CommandTypes
from ovtk_audiencekit.events.Message import Message, SysMessage, USER_TYPE from events.Message import Message, SysMessage, USER_TYPE
from ovtk_audiencekit.chats.Twitch import Process as Twitch from chats.Twitch import Process as Twitch
class ShoutoutPlugin(PluginBase): class ShoutoutPlugin(PluginBase):
def setup(self, command='so', min_level='vip', def __init__(self, *args, command='so', min_level='vip',
text='Check out {link}!~ They were last streaming {last_game}'): text='Check out {link}!~ They were last streaming {last_game}',
**kwargs):
super().__init__(*args, **kwargs)
self.text = text self.text = text
if command: if command:
self.command = Command(name=command, help='Shoutout another user', required_level=min_level) self.command = Command(name=command, help='Shoutout another user', required_level=min_level)
@ -34,6 +36,7 @@ class ShoutoutPlugin(PluginBase):
def run(self, username, _ctx={}, **kwargs): def run(self, username, _ctx={}, **kwargs):
super().run(**kwargs)
if event := _ctx.get('event'): if event := _ctx.get('event'):
text = self.make_shoutout_msg(username, event.via) text = self.make_shoutout_msg(username, event.via)
msg = SysMessage(self._name, text) msg = SysMessage(self._name, text)
@ -47,7 +50,7 @@ class ShoutoutPlugin(PluginBase):
if isinstance(event, Message): if isinstance(event, Message):
if self.command and self.command.invoked(event): if self.command and self.command.invoked(event):
try: try:
args, _ = self.command.parse(event.text) args = self.command.parse(event.text)
except ArgumentError as e: except ArgumentError as e:
msg = SysMessage(self._name, str(e), replies_to=event) msg = SysMessage(self._name, str(e), replies_to=event)
self.chats[event.via].send(msg) self.chats[event.via].send(msg)

View file

@ -1,9 +1,9 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass, asdict
import typing import typing
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import Message from events import Message
@dataclass @dataclass
@ -36,7 +36,7 @@ class Trigger:
self.source = [source.lower() for source in self.source] self.source = [source.lower() for source in self.source]
if self.monitization: if self.monitization:
self.monitization_exact = not '-' in self.monitization self.monitization_exact = '-' in self.monitization
self.monitization = list(float(bound) if bound != '' else None for bound in self.monitization.split('-')) self.monitization = list(float(bound) if bound != '' else None for bound in self.monitization.split('-'))
def matches(self, event, last_msg=None): 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__)): or (all(cls.__name__ != self.event for cls in event.__class__.__mro__)):
return False return False
if self.monitization: if self.monitization:
if not self.monitization_exact: if self.monitization_exact:
lower_bound = self.monitization[0] lower_bound = self.monitization[0]
upper_bound = self.monitization[1] if len(self.monitization) == 2 else None upper_bound = self.monitization[1] if len(self.monitization) == 2 else None
if not (event.monitization is not None if not (event.monitization is not None
@ -77,12 +77,11 @@ class Trigger:
for key, value in self.attr_checks.items(): for key, value in self.attr_checks.items():
try: try:
event_value = event.to_dict()[key] event_value = event.to_dict()[key]
if event_value != value or (str(event_value) != str(value)):
return False
except KeyError: except KeyError:
if value is not None: return False
return False if event_value != value:
except (AttributeError, TypeError): return False
except AttributeError:
# HACK: lazy bird's event type checking # HACK: lazy bird's event type checking
return False return False
@ -108,15 +107,15 @@ class TriggerPlugin(PluginBase):
unknown_args[key] = value unknown_args[key] = value
trigger = Trigger(**args, attr_checks=unknown_args) trigger = Trigger(**args, attr_checks=unknown_args)
handler = lambda ctx: self.execute_kdl(_children, _ctx=ctx) actions = [lambda event, ctx=_ctx, node=node: self.call_plugin_from_kdl(node, _ctx={**ctx, 'event': event}) for node in _children]
self.triggers.append((trigger, handler, _ctx)) self.triggers.append((trigger, actions))
async def on_bus_event(self, event): def on_bus_event(self, event):
for trigger, handler, ctx in self.triggers: for trigger, actions in self.triggers:
if trigger.matches(event, self.last_msg): if trigger.matches(event, self.last_msg):
_ctx = {**ctx, 'event': event} for action in actions:
await handler(_ctx) action(event)
if isinstance(event, Message): if isinstance(event, Message):
self.last_msg = event self.last_msg = event
return event return event

20
plugins/WebSocket.py Normal file
View file

@ -0,0 +1,20 @@
import subprocess
import websockets
from plugins import PluginBase
from events import SysMessage
from utils import make_sync
@make_sync
async def send(ws, data):
async with websockets.connect(ws) as websocket:
await websocket.send(data)
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)

View file

@ -1,7 +1,7 @@
import os import os
from ovtk_audiencekit.core import PluginBase from plugins import PluginBase
from ovtk_audiencekit.events import SysMessage, Message from events import SysMessage, Message
class WritePlugin(PluginBase): class WritePlugin(PluginBase):
@ -10,10 +10,9 @@ class WritePlugin(PluginBase):
def run(self, text, target, append=False, **kwargs): def run(self, text, target, append=False, **kwargs):
text += '\n' text += '\n'
base_name = os.path.dirname(text)
base_name = os.path.dirname(target)
if base_name: if base_name:
os.makedirs(base_name, exist_ok=True) os.makedirs(base_name, exist_ok=True)
with open(target, 'a' if append else 'w') as f: with open(target, 'a' if append else 'w') as f:
f.write(text) f.write(text)

2
plugins/__init__.py Normal file
View file

@ -0,0 +1,2 @@
from .PluginBase import PluginBase
from .Command import Command

View file

@ -1,60 +0,0 @@
[project]
name = "ovtk_audiencekit"
version = "0.1.0"
description = ""
authors = [
{name = "Skeh", email = "im@skeh.site"},
]
dependencies = [
"click",
"kdl-py",
"quart==0.18.*",
"werkzeug==2.3.7",
"hypercorn",
"requests",
"websockets==11.0.3",
"aioprocessing",
"aioscheduler",
"pyaudio==0.2.*",
"librosa==0.8.*",
"pytsmod",
"numpy",
"multipledispatch",
"blessed",
"appdirs",
"maya",
]
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"]
midi = [
"mido",
"python-rtmidi",
]
obs = ["simpleobsws"]
osc = ["python-osc>=1.9.0"]
yt-dlp = ["yt-dlp"]
[build-system]
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[tool.pdm.scripts]
start = "python audiencekit.py start"
ws = "python audiencekit.py ws"
debug = "python audiencekit.py --debug"
[tool.pdm]
[[tool.pdm.source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

View file

@ -1 +0,0 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

View file

@ -1 +0,0 @@
from .peertube import PtChatProcess as Process

View file

@ -1,70 +0,0 @@
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)

View file

@ -1,64 +0,0 @@
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")

View file

@ -1,31 +0,0 @@
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')
@click.option('--parallel', default=5)
def start(config_file, bus_bind=None, bus_port=None, web_bind=None, web_port=None, parallel=None):
"""Start audiencekit server"""
logger.info('Hewwo!!')
main = MainProcess(config_file,
bus_conf=(bus_bind, bus_port),
web_conf=(web_bind, web_port),
max_concurrent=parallel)
try:
asyncio.run(main.run())
except KeyboardInterrupt:
pass
finally:
logger.info('Suya~')

View file

@ -1,156 +0,0 @@
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__)
class Clip:
def __init__(self, path, samplerate=None, speed=1, keep_pitch=True, force_stereo=True):
self.path = path
raw, native_rate = librosa.load(self.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
self.samplerate = samplerate or native_rate
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, keep_pitch=keep_pitch)
@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, keep_pitch=True):
if keep_pitch:
stretched = tsm.wsola(self._stereo_transpose(self.raw), 1 / speed)
else:
stretched = librosa.resample(self._stereo_transpose(self.raw), self.samplerate * speed, self.samplerate, fix=False, scale=True)
self.raw = np.ascontiguousarray(self._stereo_transpose(stretched), dtype='float32')
def save(self, filename):
soundfile.write(filename, self._stereo_transpose(self.raw), self.samplerate)
class Stream:
def __init__(self, clip, output_index, buffer_length=4096):
self.clip = clip
self.pos = 0
self.playing = False
self._end_event = AioEvent()
self._stream = pyaudio.open(
output_device_index=output_index,
format=pya.paFloat32,
channels=self.clip.channels,
rate=self.clip.samplerate,
frames_per_buffer=buffer_length,
output=True,
stream_callback=self._read_callback,
start=False)
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.clip.length)
async def aplay(self):
self._end_event.clear()
self._play()
try:
await self._end_event.coro_wait(timeout=self.clip.length)
except asyncio.CancelledError:
self.playing = False
self._stream.stop_stream()
def close(self):
self._stream.stop_stream()
self._stream.close()
def _read_callback(self, in_data, frame_count, time_info, status):
if self.clip.channels > 1:
buffer = np.zeros((frame_count, self.clip.channels), dtype='float32')
else:
buffer = np.zeros((frame_count,), dtype='float32')
if self.playing:
newpos = self.pos + frame_count
clip_chunk = self.clip.raw[self.pos:newpos]
self.pos = newpos
buffer[0:clip_chunk.shape[0]] = clip_chunk
if self.pos >= self.clip.raw.shape[0]:
self.playing = False
self._end_event.set()
return buffer, pya.paContinue
@staticmethod
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
@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}')

View file

@ -1,155 +0,0 @@
from functools import reduce
from operator import getitem
from string import Formatter
from dataclasses import dataclass, field
from abc import ABC, abstractmethod
import os
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 convert_field(self, value, conversion):
if conversion == 'i':
return value.replace('\n', '')
else:
return super().convert_field(value, conversion)
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,
}
@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
self._args = self.args
self._props = self.props
def compute(self, *args, **kwargs):
args = []
for arg in self._args:
if isinstance(arg, Dynamic):
arg = arg.compute(*args, **kwargs)
args.append(arg)
props = {}
for key, prop in self._props.items():
if isinstance(prop, Dynamic):
prop = prop.compute(*args, **kwargs)
props[key] = prop
self.args = args
self.props = props
# 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.compute()
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

View file

@ -1,344 +0,0 @@
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.Config import parse_kdl_deep, kdl_reserved
from ovtk_audiencekit.core.Plugins import PluginError
from ovtk_audiencekit.core.WebsocketServerProcess import WebsocketServerProcess
from ovtk_audiencekit.events import Event, Delete
from ovtk_audiencekit.chats.ChatProcess import ShutdownRequest
from ovtk_audiencekit.plugins import builtins
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, web_conf, max_concurrent=5):
self._running = False
self.config_path = config_path
self.bus_conf = bus_conf
self.web_conf = web_conf
self.max_concurrent = max_concurrent
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:
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 = await plugin._tick(0.5) # Not necesarily honest!
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)
self.plugins[plugin_name] = plugin
await plugin._setup(*node.args[1:], **node.props, **secrets_for_mod)
# 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._kdl_call(node, global_ctx)
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()))
for i in range(0, self.max_concurrent):
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()

View file

@ -1,200 +0,0 @@
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
from ovtk_audiencekit.utils import format_exception
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)
def render(self, name, **kwargs):
"""render_template that prefers the plugin-specific templates"""
full = self.template_folder / name
if os.path.exists(full):
template_string = None
with open(full, 'r') as template_file:
template_string = template_file.read()
return quart.render_template_string(template_string, **kwargs)
else:
return quart.render_template(name, **kwargs)
class PluginBase(ABC):
plugins = {}
hooks = {} # the hookerrrrrrrr
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]
if self._name in self.hooks:
del self.hooks[self._name]
async def _kdl_call(self, node, _ctx):
node.compute(_ctx=_ctx)
subroutine = node.sub
if subroutine:
func = self
for accessor in subroutine:
func = getattr(func, accessor)
else:
func = self.run
for hook in self.hooks.values():
try:
res = hook(self._name, node, _ctx)
if asyncio.iscoroutinefunction(hook):
await res
except Exception as e:
self.logger.warning(f'Failed to run plugin hook: {e}')
self.logger.debug(format_exception(e))
try:
result = func(*node.args, _children=node.nodes, _ctx=_ctx, **node.props)
if asyncio.iscoroutinefunction(func):
result = await result
except Exception as e:
if isinstance(e, KeyboardInterrupt):
raise e
raise PluginError(self._name, str(e)) from e
if node.alias:
_ctx[node.alias] = result
async def _tick(self, *args, **kwargs):
try:
res = self.tick(*args, **kwargs)
if asyncio.iscoroutinefunction(self.tick):
return await res
else:
return res
except Exception as e:
if isinstance(e, KeyboardInterrupt):
raise e
raise PluginError(self._name, str(e)) from e
async def _setup(self, *args, **kwargs):
try:
res = self.setup(*args, **kwargs)
if asyncio.iscoroutinefunction(self.setup):
return await res
else:
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)
def register_hook(self, hook):
self.hooks[self._name] = hook
async def execute_kdl(self, nodes, _ctx={}):
"""
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:
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
await target._kdl_call(node, _ctx)
except Exception as e:
self.logger.warning(f'Failed to execute defered KDL: {e}')
self.logger.debug(format_exception(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
async def setup(self, *args, **kwargs):
"""Called when plugin is being loaded."""
pass
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, *args, _children=None, _ctx={}, **kwargs):
"""
Run plugin action, either due to a definition in the config, or due to another plugin
"""
pass

View file

@ -1,47 +0,0 @@
from dataclasses import dataclass, field
import json
import click
from ovtk_audiencekit.events import Event
@dataclass
class Control(Event):
"""Generic inter-bus communication"""
target: str = None
data: dict = field(default_factory=dict)
def __init__(self, via, target, **kwargs):
super().__init__(via)
self.target = target
self.data = kwargs
def __repr__(self):
return f"Control message : target = {self.target}, data = {self.data}"
def serialize(self):
return json.dumps({
'type': [cls.__name__ for cls in self.__class__.__mro__],
'data': {**self.data, **self.to_dict()},
})
@classmethod
def _cli(cls, cmd):
def parse(ctx, param, value):
if value:
return json.loads(value)
super_cb = cmd.callback
@click.pass_context
def expand(ctx, *args, json=None, **kwargs):
if json:
kwargs = {**json, **kwargs}
return ctx.invoke(super_cb, *args, **kwargs)
cmd.params.append(
click.Option(['--json', '-d'], callback=parse, help="Add arbitrary data in JSON format")
)
cmd.callback = expand
return cmd

View file

@ -1,82 +0,0 @@
import asyncio
from collections import deque
import maya
from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.core import Clip, Stream
class AudioAlert(PluginBase):
def setup(self, output=None, timeout_min=1, sample_rate=None, buffer_length=4096, force_stereo=True):
self._cleanup_task = asyncio.create_task(self._cleanup())
self.force_stereo = force_stereo
self.timeout_min = timeout_min
self.clips = {}
self.streams = {}
self.tasks = set()
self.buffer_length = int(buffer_length)
self.output_index = Stream.find_output_index(output)
if sample_rate is None:
try:
sample_rate = next((rate for rate in [44100, 48000] if Stream.check_rate(self.output_index, 1, rate)))
except StopIteration:
self.logger.warn('Target audio device does not claim to support common sample rates! Attempting playback at native rate of audio')
self.sample_rate = sample_rate
async def run(self, path, speed=1, keep_pitch=False, wait=False, poly=1, **kwargs):
poly = int(poly)
key = f'{path}@{speed}{"X" if keep_pitch else "x"}'
clip = self.clips.get(key, [None, None])[0]
if clip is None:
clip = Clip(path, speed=speed, keep_pitch=keep_pitch,
samplerate=self.sample_rate, force_stereo=self.force_stereo)
self.clips[key] = [clip, maya.now()]
else:
self.clips[key][1] = maya.now()
stream_dq = self.streams.get(key, None)
if stream_dq is None:
stream_dq = deque(maxlen=poly)
self.streams[key] = stream_dq
if stream_dq.maxlen != poly:
self.logger.warn('Cannot change poly while streams are active!')
if len(stream_dq) == stream_dq.maxlen:
stream_dq.rotate(1)
stream = stream_dq[0]
else:
stream = Stream(clip, self.output_index,
buffer_length=self.buffer_length)
stream_dq.append(stream)
if wait:
await stream.aplay()
else:
task = asyncio.create_task(stream.aplay())
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
def close(self):
self._cleanup_task.cancel()
for task in self.tasks:
task.cancel()
for stream_dq in self.streams.values():
for stream in stream_dq:
stream.close()
async def _cleanup(self):
while True:
await asyncio.sleep(60)
now = maya.now()
for key, [clip, last_used] in list(self.clips.items()):
if now >= last_used.add(minutes=self.timeout_min, seconds=clip.length):
self.logger.debug(f'Dropping {key}')
streams = self.streams.get(key, [])
for stream in streams:
stream.close()
del self.streams[key]
del self.clips[key]

View file

@ -1,60 +0,0 @@
import asyncio
import mido
from ovtk_audiencekit.core 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 setup(self):
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))

View file

@ -1 +0,0 @@
from .Midi import MidiPlugin as Plugin

View file

@ -1 +0,0 @@
from .obs import OBSWSPlugin as Plugin

View file

@ -1,22 +0,0 @@
import asyncio
import simpleobsws
from ovtk_audiencekit.core import PluginBase
class OBSWSPlugin(PluginBase):
async def setup(self, password=None, uri='ws://localhost:4455'):
self.uri = uri
self.obsws = simpleobsws.WebSocketClient(url=uri, password=password)
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

View file

@ -1 +0,0 @@
from .osc import OSCPlugin as Plugin

View file

@ -1,16 +0,0 @@
from pythonosc.udp_client import SimpleUDPClient
from ovtk_audiencekit.core import PluginBase
class OSCPlugin(PluginBase):
def setup(self, ip='localhost', port=None):
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)

View file

@ -1,114 +0,0 @@
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.core import PluginBase
from ovtk_audiencekit.events import Message, SysMessage
from ovtk_audiencekit.core import Clip, Stream
from ovtk_audiencekit.core.Data import CACHE_DIR
class TextToSpeechPlugin(PluginBase):
def setup(self, output=None, cuda=None,
engine="tts_models/en/ljspeech/tacotron2-DDC", speaker_wav=None, **kwargs):
self.speaker_wav = speaker_wav
self.output_index = Stream.find_output_index(output)
sample_rate = None
try:
sample_rate = next((rate for rate in [44100, 48000] if Stream.check_rate(self.output_index, 1, rate)))
except StopIteration:
self.logger.warn('Target audio device does not claim to support common sample rates! Attempting playback at native rate of audio')
self.sample_rate = sample_rate
conf_overrides = {k[2:]: v for k, v in kwargs.items() if k.startswith('o_')}
self.cache_dir = os.path.join(CACHE_DIR, 'tts')
os.makedirs(os.path.dirname(self.cache_dir), 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_dir, 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,
)
self.tasks = set()
def close(self):
for task in self.tasks:
task.cancel()
def make_tts_wav(self, text, filename=None):
# Force punctuation (keeps the models from acting unpredictably)
text = text.strip()
if not any([text.endswith(punc) for punc in '.!?:']):
text += '.'
if filename is None:
filename = os.path.join(self.cache_dir, f'{uuid.uuid1()}.wav')
self.logger.info(f'Generating TTS "{text}"...')
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)
self.logger.info(f'Done - saved as {filename}')
return filename
async def run(self, text, *args, _ctx={}, wait=False, **kwargs):
try:
# Do TTS processing in a thread to avoid blocking main loop
filename = await asyncio.get_running_loop().run_in_executor(None, self.make_tts_wav, text)
# TODO: Play direct from memory
clip = Clip(filename, force_stereo=True, samplerate=self.sample_rate)
stream = Stream(clip, self.output_index)
async def play():
try:
await stream.aplay()
finally:
stream.close()
os.remove(os.path.join(self.cache_dir, filename))
task = asyncio.create_task(play())
self.tasks.add(task)
task.add_done_callback(self.tasks.discard)
if wait:
await task
except Exception as e:
self.logger.error(f"Failed to make speech from input: {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)

View file

@ -1 +0,0 @@
from .TTS import TextToSpeechPlugin as Plugin

View file

@ -1 +0,0 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

View file

@ -1,128 +0,0 @@
import asyncio
import logging
import datetime
import uuid
import maya
import aioscheduler
from ovtk_audiencekit.core 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():
# Repetition 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}')
def close(self):
self._cleanup_task.cancel()
for task in self.tasks.values():
self.scheduler.cancel(task)
self.scheduler._task.cancel()
async def _cleanup(self):
while True:
await asyncio.sleep(60)
for name, (cue, _) in list(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]

View file

@ -1,32 +0,0 @@
import os
from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import SysMessage, Message
def compute_recursive(node, _ctx):
node.compute(_ctx=_ctx)
for child in node.nodes:
compute_recursive(child, _ctx)
class ExtendPlugin(PluginBase):
"""Extend a KDL file with the contained node"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
async def run(self, target, compute=False, run=False, _children=None, _ctx={}):
if _children is None:
return
if target:
base_name = os.path.dirname(target)
if base_name:
os.makedirs(base_name, exist_ok=True)
with open(target, 'a') as f:
for node in _children:
if compute:
compute_recursive(node, _ctx)
f.write(str(node))
if run:
await self.execute_kdl(_children, _ctx=_ctx)

View file

@ -1,14 +0,0 @@
from ovtk_audiencekit.core 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)

View file

@ -1,53 +0,0 @@
import dataclasses
from enum import Enum
from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import Event
from ovtk_audiencekit.utils import get_subclasses
class EvFactory:
def __init__(self, ev_class):
self.target_class = ev_class
self.arg_convs = []
self.kwarg_convs = {}
for field in dataclasses.fields(self.target_class):
required = all(isinstance(default, dataclasses.MISSING.__class__) for default in [field.default, field.default_factory])
if issubclass(field.type, Enum):
conv = lambda value: field.type.__members__[value]
else:
conv = None
if required:
self.arg_convs.append(conv)
else:
self.kwarg_convs[field.name] = conv
def make(self, via, *args, **kwargs):
args = [
conv(arg) if conv else arg
for arg, conv in zip(args, self.arg_convs)
]
kwargs = {
key: self.kwarg_convs[key](value) if self.kwarg_convs.get(key) else value
for key, value in kwargs.items()
}
return self.target_class(via, *args, **kwargs)
class MakeEventPkugin(PluginBase):
"""Create a new event and send it to the event bus"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.event_factories = {}
for event_class in get_subclasses(Event):
event_name = event_class.__name__
self.event_factories[event_name] = EvFactory(event_class)
def run(self, event_name, *args, _children=[], _ctx={}, **kwargs):
event_factory = self.event_factories.get(event_name)
if event_factory is None:
raise ValueError(f'Unknown event type "{event_name}"')
ev = event_factory.make('kdl', *args, **kwargs)
self.send_to_bus(ev)

View file

@ -1,153 +0,0 @@
from dataclasses import dataclass, field
import asyncio
from typing import Callable
import quart
import json
import kdl
from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.utils import format_exception
@dataclass
class Scene:
name: str
group: str
oneshot: bool
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, oneshot=False, **kwargs):
if _children is None:
raise UsageError('Empty scene definition! Did you mean scene.set?')
await self.define(name, group, _children, default_active=active, oneshot=oneshot, ctx=_ctx)
async def set(self, name, _children=None, _ctx={}, active=True, wait=False):
await self.switch(name, active, is_immediate=not wait, ctx=_ctx)
async def define(self, name, group, children, default_active=False, oneshot=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, oneshot, 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 scene.oneshot:
await self._execute(scene, 'enter', is_immediate, ctx)
elif 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 self.blueprint.render('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()

View file

@ -1 +0,0 @@
from .Plugin import ScenePlugin, Scene

View file

@ -1,85 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Scene control</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 (!groups.value[group_name][scene_name].oneshot) {
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>

View file

@ -1,39 +0,0 @@
from ovtk_audiencekit.core import PluginBase
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)
child.compute(_ctx=stack[0][1])
self.proc_node(sub, *child.args, _children=child.nodes, _stack=stack, **child.props)
elif len(args) > 0:
name, target = _stack[-1]
target[name] = args[0] if len(args) == 1 else args

View file

@ -1,9 +0,0 @@
from ovtk_audiencekit.core import PluginBase
import asyncio
class WaitPlugin(PluginBase):
"""Halt execution for a bit"""
async def run(self, seconds=0, minutes=0, **kwargs): # If you want `hours`, why??????
await asyncio.sleep(seconds + (minutes * 60))

View file

@ -1,19 +0,0 @@
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 .Set import SetPlugin as set
from .Scene import ScenePlugin as scene
from .Log import LogPlugin as log
from .Mkevent import MakeEventPkugin as mkevent
from .Wait import WaitPlugin as wait
from .Extend import ExtendPlugin as extend
__all__ = [
'trigger', 'reply', 'command', 'cue', 'write', 'exec', 'chance', 'choice',
'set', 'scene', 'log', 'mkevent', 'wait', 'extend'
]

View file

@ -1,86 +0,0 @@
import asyncio
import functools
from yt_dlp import YoutubeDL, DownloadCancelled
from ovtk_audiencekit.core import PluginBase
filter_aliases = {
'views': 'view_count',
'likes': 'like_count',
'comments': 'comment_count',
'followers': 'channel_follower_count',
'subscribers': 'channel_follower_count', 'subs': 'channel_follower_count',
'length': 'duration',
}
class Plugin(PluginBase):
async def run(self, source_url, destination, audio_only=False, format=None,
_children=None, _ctx={}, **kwargs):
filter_fn, fail_node = self.make_filter(_children) if _children else (None, None)
if format == 'ogg':
format = 'vorbis'
params = {
'fragment_retries': 10, 'retries': 10,
'noplaylist': True, 'noprogress': True,
'logger': self.logger,
'match_filter': filter_fn, 'break_on_reject': True,
'outtmpl': {'default': destination},
'final_ext': format,
'postprocessors': [{
'key': 'FFmpegExtractAudio', 'preferredcodec': format,
} if audio_only else {
'key': 'FFmpegVideoConvertor', 'preferedformat': format,
}],
**kwargs,
}
loop = asyncio.get_running_loop()
try:
with YoutubeDL(params) as ytdl:
info = await loop.run_in_executor(None, ytdl.extract_info, source_url)
except DownloadCancelled as e:
if fail_node:
await self.execute_kdl(fail_node.nodes, _ctx=_ctx)
if fail_node.props.get("continue"):
return
raise e
filename = info['requested_downloads'][0]['filepath']
return filename
def make_filter(self, nodes):
fail_node = None
nodes = nodes.copy()
if index := next((index for index, node in enumerate(nodes) if node.name == 'on_fail'), None):
fail_node = nodes.pop(index)
def filter(_nodes, info, *args):
for node in _nodes:
field = node.name
if real_field := filter_aliases.get(field):
field = real_field
value = info.get(field)
if value is None:
if node.props.get('strict'):
return f"Missing metadata: {field}"
else:
continue
if target := node.props.get('min'):
if value < target:
return f"Failed {field} check: min {target}, got {value}"
if target := node.props.get('max'):
if value >= target:
return f"Failed {field} check: max {target}, got {value}"
if len(node.args) == 1:
target = node.args[0]
if value != target:
return f"Failed {field} check: wanted {target}, got {value}"
return functools.partial(filter, nodes), fail_node

View file

@ -1,4 +0,0 @@
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))

View file

@ -1,4 +1,5 @@
from multiprocessing import Process, Pipe from multiprocessing import Process, Pipe
from traceback import format_exception
import logging import logging
import asyncio import asyncio
@ -43,18 +44,18 @@ class NonBlockingWebsocket(Process):
self._pipe.send(data) self._pipe.send(data)
def run(self): def run(self):
loop = asyncio.new_event_loop()
# Setup ws client # Setup ws client
loop.run_until_complete(self._setup()) asyncio.get_event_loop().run_until_complete(self._setup())
# Make an awaitable object that flips when the pipe's underlying file descriptor is readable # Make an awaitable object that flips when the pipe's underlying file descriptor is readable
pipe_ready = asyncio.Event() pipe_ready = asyncio.Event()
loop.add_reader(self._pipe.fileno(), pipe_ready.set) asyncio.get_event_loop().add_reader(self._pipe.fileno(), pipe_ready.set)
# Make and start our infinite tasks # Make and start our infinite tasks
loop.create_task(self._send(pipe_ready)) asyncio.get_event_loop().create_task(self._send(pipe_ready))
loop.create_task(self._read()) asyncio.get_event_loop().create_task(self._read())
# Keep the asyncio code running in this thread until explicitly stopped # Keep the asyncio code running in this thread until explicitly stopped
try: try:
loop.run_forever() asyncio.get_event_loop().run_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
return 0 return 0

View file

@ -1,4 +1,3 @@
from .NonBlockingWebsocket import NonBlockingWebsocket from .NonBlockingWebsocket import NonBlockingWebsocket
from .make_sync import make_sync from .make_sync import make_sync
from .get_subclasses import get_subclasses from .get_subclasses import get_subclasses
from .format_exception import format_exception