Compare commits

..

13 commits

Author SHA1 Message Date
9f33ad634e [plugins] Add pre-call hooks
This also greatly simplifies execution of the kdl script thing format. I 
have no idea why it was like that before
2025-02-04 06:35:51 -05:00
3b6a6df0eb Allow plugins running at top level to assign to global ctx 2025-02-04 06:35:51 -05:00
cd636e47d4 [builtins/wait] Allow halting one of the concurrent channels on purpose 2025-02-04 06:35:51 -05:00
bea110b99e Concurrency get!
+ Your cursed setup can now do multiple things at once (GIL and 
unblocked main thread willing), tune with --parallel
+ Swaps "immediate" option for it's inverse "wait" in most plugins
+ Do some expensive ops in a threadpool
2025-02-04 06:35:51 -05:00
4521250ac1 [events/control] Make control events easier to generate
On-bus format is the same, so this is not a breaking change~

Enables events to get full access to their click command for more polish
2025-02-04 06:35:51 -05:00
439d9943ab [builtins/mkevent] Builtin that allows creating events from kdl! 2025-02-04 06:35:51 -05:00
72dbd2a232 [builtins/command] Add unused text to ctx 2025-02-04 06:35:51 -05:00
eabcba5d16 Allow expanding plugins via namespace
PYTHONPATH your way into this package if ya want ;)
2025-02-04 06:35:51 -05:00
Derek
9311245ef0 Fix scene typo 2025-02-04 06:35:51 -05:00
Derek
476552bbf5 Fix pdm lock 2025-02-04 06:35:51 -05:00
ccb86d4c02 Update config 2025-02-04 06:35:51 -05:00
75e9c3dcc2 [builtins/scene] scene ... active=true -> scene.set ...
You can still deactivate by using `scene.set "name" active=false`
2025-02-04 06:35:51 -05:00
d4597620fe [builtins] Remove raw websocket, demote midi to optional 2025-02-04 06:35:51 -05:00
39 changed files with 854 additions and 998 deletions

View file

@ -27,7 +27,7 @@ trigger event="Raid" {
## For developers ## For developers
Extending audiencekit's automation abilities is made as simple as possible. For example, a plugin that automatically changes "simp" to "shrimp" in every message (as seen by those consuming this project's output) is simply: 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.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import Message from ovtk_audiencekit.events import Message
class Plugin(PluginBase): class Plugin(PluginBase):

View file

@ -1,7 +1,7 @@
/* Comments surrounded by asterisks and slashes (like this one) are instructions, /* Comments surrounded by asterisks and slashes (like this one) are instructions,
comments staring with two slashes are example configuration! */ comments staring with two slashes are valid example configuration! */
/* Step 1: Add your credentials */ /* Step 1: Give it the rights (to party) */
secrets { secrets {
/* Generate credentials via https://ovtk.skeh.site/twitch/auth /* Generate credentials via https://ovtk.skeh.site/twitch/auth
and paste them between the curly braces below */ and paste them between the curly braces below */
@ -17,46 +17,52 @@ secrets {
} }
} }
/* Step 2: Import modules
/* Step 2: Import what you need
There are two types of modules, chats and plugins: There are two types of modules, chats and plugins:
+ Chats are self-explanitory: the event providers - livestream services. + Chats are self-explanitory: the event providers - livestream services.
+ Plugins are the heart of the system, and can both monitor livestream events + Plugins are the heart of the system, and can both monitor livestream events
and be called by name in your config files to perform actions. and be called on directly to perform actions.
Some plugins (called "builtins" and always starting with a lowercase character) Some plugins (called "builtins") are always available, but others are not loaded
are always available, but others are not loaded until you ask to conserve until you ask. Chats are never loaded by default.
resources. Chats are never loaded by default.
You can load either by using the "chat" or "plugin" node respectively, 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 and supplying the name of the module. You can also rename them by seperating
the module name and the new name with a colon (:) 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" // chat "Twitch" channel_name="MyTwitchChannel"
// chat "Twitch:otherguy" channel_name="SomeOtherChannel" // guest:chat "Twitch" channel_name="CollabChannel" readonly=true
// plugin "AudioAlert" output="ALSA:default" // aplay:plugin "AudioAlert" output="ALSA:default"
/* Step 3: Kick it /* Step 3: Get silly with it
Some example automations are provided below to get you started. Some example automations are provided below to get you started.
See the wiki for more details: https://git.skeh.site/skeh/ovtk_audiencekit/wiki 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 10 min */ /* Self-promo every 2 hours */
// cue { // cue hours=2 {
// every hours=2 { // reply "Like the setup? Run it yourself! https://git.skeh.site/explore/repos?q=ovtk&topic=1"
// reply "Like the setup? Run it yourself! https://git.skeh.site/explore/repos?q=ovtk&topic=1"
// }
// } // }
/* Call an existing shoutout bot on raid */ /* Call an existing shoutout bot on raid */
// trigger event="Raid" { // trigger event="Raid" {
// reply (arg)"!so {event.from_channel}" // reply (t)"!so {event.from_channel}"
// } // }
/* Lurk command */ /* Lurk command (!lurk) */
// command "lurk" help="Lurke modeo" display=true { // command "lurk" help="Lurke modeo" display=true {
// do { // do {
// reply "The Twitch algorithm thanks you for the lurk~" display=true // reply "The Twitch algorithm thanks you for the lurk~" display=true
@ -65,16 +71,57 @@ secrets {
/* TTS for every donation event */ /* TTS for every donation event */
// plugin "TTS" // plugin "TTS"
// trigger monitization="0.01-" source="self" { // trigger monitization="0.01-" {
// TTS (arg)"{event.user_name} says: {event.text}" // TTS (t)"{event.user_name} says: {event.text}"
// } // }
/* Voice effect channel redeem (via midi to physical effect rack / DAW) */ /* 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" { // trigger event="ChannelPointRedemption" action="Sound Sussy" {
// midi "sysex" data="FunEffect;1" // Midi "program_change" channel=1 program=1
// cue { // cue minutes=5 {
// after minutes=5 { // Midi "program_change" channel=1 program=0
// midi "sysex" data="FunEffect;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
*/

1394
pdm.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,10 @@ dependencies = [
"click", "click",
"kdl-py", "kdl-py",
"quart==0.18.*", "quart==0.18.*",
"werkzeug==2.3.7",
"hypercorn", "hypercorn",
"requests", "requests",
"websockets", "websockets==11.0.3",
"aioprocessing", "aioprocessing",
"aioscheduler", "aioscheduler",
"pyaudio==0.2.*", "pyaudio==0.2.*",
@ -22,10 +23,6 @@ dependencies = [
"blessed", "blessed",
"appdirs", "appdirs",
"maya", "maya",
"mido",
"python-rtmidi",
"simpleobsws",
"python-osc>=1.9.0",
] ]
requires-python = ">=3.10,<3.11" requires-python = ">=3.10,<3.11"
readme = "README.md" readme = "README.md"
@ -39,11 +36,12 @@ tts = [
phrasecounter = ["num2words"] phrasecounter = ["num2words"]
jail = ["owoify-py==2.*"] jail = ["owoify-py==2.*"]
twitch = ["miniirc"] twitch = ["miniirc"]
midi = [
[tool.pdm.dev-dependencies] "mido",
dev = [ "python-rtmidi",
"pipenv-setup",
] ]
obs = ["simpleobsws"]
osc = ["python-osc>=1.9.0"]
[build-system] [build-system]
requires = ["pdm-backend"] requires = ["pdm-backend"]
@ -52,6 +50,7 @@ build-backend = "pdm.backend"
[tool.pdm.scripts] [tool.pdm.scripts]
start = "python audiencekit.py start" start = "python audiencekit.py start"
ws = "python audiencekit.py ws" ws = "python audiencekit.py ws"
debug = "python audiencekit.py --debug"
[tool.pdm] [tool.pdm]
[[tool.pdm.source]] [[tool.pdm.source]]

View file

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

View file

@ -15,12 +15,14 @@ logger = logging.getLogger(__name__)
@click.option('--bus-port', default='8080') @click.option('--bus-port', default='8080')
@click.option('--web-bind', default='localhost') @click.option('--web-bind', default='localhost')
@click.option('--web-port', default='8000') @click.option('--web-port', default='8000')
def start(config_file, bus_bind=None, bus_port=None, web_bind=None, web_port=None): @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""" """Start audiencekit server"""
logger.info('Hewwo!!') logger.info('Hewwo!!')
main = MainProcess(config_file, main = MainProcess(config_file,
bus_conf=(bus_bind, bus_port), bus_conf=(bus_bind, bus_port),
web_conf=(web_bind, web_port)) web_conf=(web_bind, web_port),
max_concurrent=parallel)
try: try:
asyncio.run(main.run()) asyncio.run(main.run())
except KeyboardInterrupt: except KeyboardInterrupt:

View file

@ -76,10 +76,11 @@ class EventCommandFactory(click.MultiCommand):
param = click.Option(param_decls=[f'--{field.name}'], type=param_type, default=field.default, show_default=True) param = click.Option(param_decls=[f'--{field.name}'], type=param_type, default=field.default, show_default=True)
params.append(param) params.append(param)
if target_event.__dict__.get('__cli__'):
params.extend(target_event.__cli__())
command = click.Command(name, callback=mkevent_generic, params=params, help=target_event.__doc__) 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

View file

@ -17,11 +17,12 @@ import hypercorn
import hypercorn.asyncio import hypercorn.asyncio
import hypercorn.logging import hypercorn.logging
from ovtk_audiencekit.core import WebsocketServerProcess
from ovtk_audiencekit.core.Config import parse_kdl_deep, kdl_reserved, compute_dynamic from ovtk_audiencekit.core.Config import parse_kdl_deep, kdl_reserved, compute_dynamic
from ovtk_audiencekit.core.Plugins import PluginError
from ovtk_audiencekit.core.WebsocketServerProcess import WebsocketServerProcess
from ovtk_audiencekit.events import Event, Delete from ovtk_audiencekit.events import Event, Delete
from ovtk_audiencekit.chats.ChatProcess import ShutdownRequest from ovtk_audiencekit.chats.ChatProcess import ShutdownRequest
from ovtk_audiencekit.plugins import builtins, PluginError from ovtk_audiencekit.plugins import builtins
from ovtk_audiencekit.utils import format_exception from ovtk_audiencekit.utils import format_exception
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -52,11 +53,12 @@ def import_or_reload_mod(module_name, default_package=None, external=False):
class MainProcess: class MainProcess:
def __init__(self, config_path, bus_conf=(None, None), web_conf=(None, None)): def __init__(self, config_path, bus_conf, web_conf, max_concurrent=5):
self._running = False self._running = False
self.config_path = config_path self.config_path = config_path
self.bus_conf = bus_conf self.bus_conf = bus_conf
self.web_conf = web_conf self.web_conf = web_conf
self.max_concurrent = max_concurrent
self.chat_processes = {} self.chat_processes = {}
self.plugins = {} self.plugins = {}
@ -129,7 +131,6 @@ class MainProcess:
if e.fatal: if e.fatal:
self._unload_plugin(e.source) self._unload_plugin(e.source)
except Exception as e: except Exception as e:
self._plugin_error
logger.critical(f'Failure when processing {plugin_name} ({e}) - disabling...') logger.critical(f'Failure when processing {plugin_name} ({e}) - disabling...')
logger.debug(format_exception(e)) logger.debug(format_exception(e))
self._unload_plugin(plugin_name) self._unload_plugin(plugin_name)
@ -237,7 +238,7 @@ class MainProcess:
if plugin_module is None: if plugin_module is None:
logger.error(f'Unknown plugin: {node.name}') logger.error(f'Unknown plugin: {node.name}')
else: else:
await plugin_module._call(node.sub, node.tag, *node.args, **node.props, _ctx=global_ctx, _children=node.nodes) await plugin_module._kdl_call(node, global_ctx)
async def user_shutdown(self): async def user_shutdown(self):
for process_name, process in list(reversed(self.chat_processes.items())): for process_name, process in list(reversed(self.chat_processes.items())):
@ -293,7 +294,8 @@ class MainProcess:
await self.user_setup() await self.user_setup()
# Start plumbing tasks # Start plumbing tasks
user_tasks.add(loop.create_task(self.tick_plugins())) user_tasks.add(loop.create_task(self.tick_plugins()))
user_tasks.add(loop.create_task(self.handle_events())) 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') logger.info(f'Ready to rumble! Press Ctrl+C to shut down')
reload_task = loop.create_task(self.reload_ev.wait()) reload_task = loop.create_task(self.reload_ev.wait())

View file

@ -9,6 +9,8 @@ import kdl
import quart import quart
from ovtk_audiencekit.core.Config import kdl_parse_config, compute_dynamic from ovtk_audiencekit.core.Config import kdl_parse_config, compute_dynamic
from ovtk_audiencekit.utils import format_exception
class PluginError(Exception): class PluginError(Exception):
@ -30,6 +32,7 @@ class OvtkBlueprint(quart.Blueprint):
class PluginBase(ABC): class PluginBase(ABC):
plugins = {} plugins = {}
hooks = {} # the hookerrrrrrrr
def __init__(self, chat_processes, event_queue, name, global_ctx, _children=None, **kwargs): def __init__(self, chat_processes, event_queue, name, global_ctx, _children=None, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
@ -56,24 +59,41 @@ class PluginBase(ABC):
def __del__(self): def __del__(self):
if self.plugins.get(self._name) == self: if self.plugins.get(self._name) == self:
del self.plugins[self._name] del self.plugins[self._name]
if self._name in self.hooks:
del self.hooks[self._name]
async def _kdl_call(self, node, _ctx):
args, props = compute_dynamic(node, _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))
async def _call(self, subroutine, tag, *args, **kwargs):
try: try:
if subroutine: result = func(*args, _children=node.nodes, _ctx=_ctx, **props)
func = self
for accessor in subroutine:
func = getattr(func, accessor)
else:
func = self.run
res = func(*args, **kwargs)
if asyncio.iscoroutinefunction(func): if asyncio.iscoroutinefunction(func):
res = await res result = await result
return res
except Exception as e: except Exception as e:
if isinstance(e, KeyboardInterrupt): if isinstance(e, KeyboardInterrupt):
raise e raise e
raise PluginError(self._name, str(e)) from e raise PluginError(self._name, str(e)) from e
if node.alias:
_ctx[node.alias] = result
async def _tick(self, *args, **kwargs): async def _tick(self, *args, **kwargs):
try: try:
res = self.tick(*args, **kwargs) res = self.tick(*args, **kwargs)
@ -98,6 +118,7 @@ class PluginBase(ABC):
raise e raise e
raise PluginError(self._name, str(e)) from e raise PluginError(self._name, str(e)) from e
# Base class helpers # Base class helpers
def broadcast(self, event): def broadcast(self, event):
"""Send event to every active chat""" """Send event to every active chat"""
@ -106,7 +127,10 @@ class PluginBase(ABC):
continue continue
proc.control_pipe.send(event) proc.control_pipe.send(event)
async def execute_kdl(self, nodes, *py_args, _ctx={}, **py_props): 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 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! If this was done in response to an event, pass it as 'event' in _ctx!
@ -114,16 +138,14 @@ class PluginBase(ABC):
_ctx = copy.deepcopy({**self._global_ctx, **_ctx}) _ctx = copy.deepcopy({**self._global_ctx, **_ctx})
for node in nodes: for node in nodes:
try: try:
args, props = compute_dynamic(node, _ctx=_ctx)
target = self.plugins.get(node.name) target = self.plugins.get(node.name)
if target is None: if target is None:
self.logger.warning(f'Could not find plugin or builtin with name {node.name}') self.logger.warning(f'Could not find plugin or builtin with name {node.name}')
break break
result = await target._call(node.sub, node.tag, *args, *py_args, **props, _ctx=_ctx, **py_props, _children=node.nodes) await target._kdl_call(node, _ctx)
if node.alias:
_ctx[node.alias] = result
except Exception as e: except Exception as e:
self.logger.warning(f'Failed to execute defered KDL: {e}') self.logger.warning(f'Failed to execute defered KDL: {e}')
self.logger.debug(format_exception(e))
break break
@ -134,6 +156,7 @@ class PluginBase(ABC):
""" """
self._event_queue.put_nowait(event) self._event_queue.put_nowait(event)
# User-defined # User-defined
async def setup(self, *args, **kwargs): async def setup(self, *args, **kwargs):
"""Called when plugin is being loaded.""" """Called when plugin is being loaded."""

View file

@ -1,3 +1,4 @@
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 from .Audio import Clip, Stream

View file

@ -1,20 +1,47 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
import json import json
import click
from ovtk_audiencekit.events import Event from ovtk_audiencekit.events import Event
@dataclass @dataclass
class Control(Event): class Control(Event):
"""Generic inter-bus communication""" """Generic inter-bus communication"""
target: str target: str = None
data: dict = field(default_factory=dict) data: dict = field(default_factory=dict)
def __init__(self, via, target, **kwargs):
super().__init__(via)
self.target = target
self.data = kwargs
def __repr__(self): def __repr__(self):
return f"Control message : target = {self.target}, data = {self.data}" return f"Control message : target = {self.target}, data = {self.data}"
def serialize(self): def serialize(self):
return json.dumps({ return json.dumps({
'type': [cls.__name__ for cls in self.__class__.__mro__], 'type': [cls.__name__ for cls in self.__class__.__mro__],
'data': {**self.data, 'target': self.target}, '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

@ -43,14 +43,13 @@ class Message(Event):
return super().hydrate(user_type=user_type, **kwargs) return super().hydrate(user_type=user_type, **kwargs)
@classmethod @classmethod
def __cli__(cls): def _cli(cls, cmd):
def dono(ctx, param, value): def dono(ctx, param, value):
if value: if value:
return [value, value] return [value, value]
return [ cmd.params.append(click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono))
click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono), return cmd
]
@dataclass @dataclass

View file

@ -28,11 +28,12 @@ class Subscription(Event):
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 @classmethod
def __cli__(cls): def _cli(cls, cmd):
def userfactory(ctx, param, value): def userfactory(ctx, param, value):
if value: if value:
return [User(user_name=name, user_id=name) for name in value] return [User(user_name=name, user_id=name) for name in value]
return [ cmd.params.append(
click.Option(['--gifted_to', '-g'], type=click.STRING, callback=userfactory, multiple=True), click.Option(['--gifted_to', '-g'], type=click.STRING, callback=userfactory, multiple=True)
] )
return cmd

View file

@ -7,3 +7,4 @@ from .Subscription import Subscription
from .Follow import Follow from .Follow import Follow
__all__ = ['Event', 'Message', 'SysMessage', 'Delete', 'Control', 'Subscription', 'Follow'] __all__ = ['Event', 'Message', 'SysMessage', 'Delete', 'Control', 'Subscription', 'Follow']
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

View file

@ -3,7 +3,7 @@ from collections import deque
import maya import maya
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.core import Clip, Stream from ovtk_audiencekit.core import Clip, Stream
class AudioAlert(PluginBase): class AudioAlert(PluginBase):
@ -24,7 +24,7 @@ class AudioAlert(PluginBase):
self.logger.warn('Target audio device does not claim to support common sample rates! Attempting playback at native rate of audio') 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 self.sample_rate = sample_rate
def run(self, path, speed=1, keep_pitch=False, immediate=True, poly=1, **kwargs): async def run(self, path, speed=1, keep_pitch=False, wait=False, poly=1, **kwargs):
poly = int(poly) poly = int(poly)
key = f'{path}@{speed}{"X" if keep_pitch else "x"}' key = f'{path}@{speed}{"X" if keep_pitch else "x"}'
clip = self.clips.get(key, [None, None])[0] clip = self.clips.get(key, [None, None])[0]
@ -53,12 +53,12 @@ class AudioAlert(PluginBase):
stream_dq.append(stream) stream_dq.append(stream)
if immediate: if wait:
task = asyncio.create_task(stream.aplay()) await stream.aplay()
task.add_done_callback(self.tasks.remove)
self.tasks.add(task)
else: else:
stream.play() task = asyncio.create_task(stream.aplay())
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
def close(self): def close(self):
self._cleanup_task.cancel() self._cleanup_task.cancel()

View file

@ -7,7 +7,7 @@ import maya
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from owoify.owoify import owoify, Owoness from owoify.owoify import owoify, Owoness
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.core.Data import CACHE_DIR from ovtk_audiencekit.core.Data import CACHE_DIR
from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes
from ovtk_audiencekit.events.Message import Message, SysMessage from ovtk_audiencekit.events.Message import Message, SysMessage
@ -87,7 +87,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 +117,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()

View file

@ -2,7 +2,7 @@ import asyncio
import mido import mido
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
def matches(msg, attrs): def matches(msg, attrs):
@ -14,8 +14,7 @@ def matches(msg, attrs):
class MidiPlugin(PluginBase): class MidiPlugin(PluginBase):
def __init__(self, *args, **kwargs): def setup(self):
super().__init__(*args, **kwargs)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
def callback(msg): def callback(msg):
asyncio.run_coroutine_threadsafe(self.recv_callback(msg), loop) asyncio.run_coroutine_threadsafe(self.recv_callback(msg), loop)

View file

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

View file

@ -2,7 +2,7 @@ import asyncio
import simpleobsws import simpleobsws
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
class OBSWSPlugin(PluginBase): class OBSWSPlugin(PluginBase):

View file

@ -1,6 +1,6 @@
from pythonosc.udp_client import SimpleUDPClient from pythonosc.udp_client import SimpleUDPClient
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
class OSCPlugin(PluginBase): class OSCPlugin(PluginBase):

View file

@ -4,7 +4,7 @@ import json
import os import os
from ovtk_audiencekit.core.Data import CACHE_DIR from ovtk_audiencekit.core.Data import CACHE_DIR
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import Message from ovtk_audiencekit.events import Message
from .Formatter import PhraseCountFormatter from .Formatter import PhraseCountFormatter

View file

@ -2,7 +2,7 @@ from argparse import ArgumentError
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes from ovtk_audiencekit.plugins.builtins.Command import Command, CommandTypes
from ovtk_audiencekit.events.Message import Message, SysMessage, USER_TYPE from ovtk_audiencekit.events.Message import Message, SysMessage, USER_TYPE
from ovtk_audiencekit.chats.Twitch import Process as Twitch from ovtk_audiencekit.chats.Twitch import Process as Twitch
@ -47,7 +47,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

@ -6,7 +6,7 @@ from TTS.utils.synthesizer import Synthesizer
from TTS.utils.manage import ModelManager from TTS.utils.manage import ModelManager
from TTS.config import load_config from TTS.config import load_config
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import Message, SysMessage from ovtk_audiencekit.events import Message, SysMessage
from ovtk_audiencekit.core import Clip, Stream from ovtk_audiencekit.core import Clip, Stream
from ovtk_audiencekit.core.Data import CACHE_DIR from ovtk_audiencekit.core.Data import CACHE_DIR
@ -19,7 +19,6 @@ class TextToSpeechPlugin(PluginBase):
self.speaker_wav = speaker_wav self.speaker_wav = speaker_wav
self.output_index = Stream.find_output_index(output) self.output_index = Stream.find_output_index(output)
sample_rate = None
try: try:
sample_rate = next((rate for rate in [44100, 48000] if Stream.check_rate(self.output_index, 1, rate))) sample_rate = next((rate for rate in [44100, 48000] if Stream.check_rate(self.output_index, 1, rate)))
except StopIteration: except StopIteration:
@ -59,6 +58,12 @@ class TextToSpeechPlugin(PluginBase):
use_cuda=self.cuda, 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): def make_tts_wav(self, text, filename=None):
if filename is None: if filename is None:
filename = os.path.join(self.cache_dir, f'{uuid.uuid1()}.wav') filename = os.path.join(self.cache_dir, f'{uuid.uuid1()}.wav')
@ -71,25 +76,29 @@ class TextToSpeechPlugin(PluginBase):
self.synthesizer.save_wav(wav, filename) self.synthesizer.save_wav(wav, filename)
return filename return filename
async def run(self, text, *args, _ctx={}, wait=True, **kwargs): async def run(self, text, *args, _ctx={}, wait=False, **kwargs):
try: try:
# Force punctuation (keep AI from spinning off into random noises) # Force punctuation (keep AI from spinning off into random noises)
if not any([text.endswith(punc) for punc in '.!?:']): if not any([text.endswith(punc) for punc in '.!?:']):
text += '.' text += '.'
filename = self.make_tts_wav(text) # 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 # TODO: Play direct from memory
clip = Clip(filename, force_stereo=True, samplerate=self.sample_rate) clip = Clip(filename, force_stereo=True, samplerate=self.sample_rate)
stream = Stream(clip, self.output_index) stream = Stream(clip, self.output_index)
if wait: async def play():
async def play(): try:
await stream.aplay() await stream.aplay()
finally:
stream.close() stream.close()
os.remove(os.path.join(self.cache_dir, filename)) os.remove(os.path.join(self.cache_dir, filename))
asyncio.create_task(play()) task = asyncio.create_task(play())
else: self.tasks.add(task)
stream.play() task.add_done_callback(self.tasks.discard)
stream.close()
os.remove(os.path.join(self.cache_dir, filename)) if wait:
await task
except Exception as e: except Exception as e:
self.logger.error(f"Failed to make speech from input: {e}") self.logger.error(f"Failed to make speech from input: {e}")
if source_event := _ctx.get('event'): if source_event := _ctx.get('event'):

View file

@ -1,3 +1 @@
from ovtk_audiencekit.core.PluginBase import PluginBase, PluginError __path__ = __import__('pkgutil').extend_path(__path__, __name__)
__all__ = ['PluginBase', 'PluginError']

View file

@ -1,6 +1,6 @@
import random import random
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
class ChancePlugin(PluginBase): class ChancePlugin(PluginBase):

View file

@ -1,7 +1,7 @@
import subprocess import subprocess
import random import random
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import SysMessage from ovtk_audiencekit.events import SysMessage

View file

@ -7,7 +7,7 @@ import sys
from multipledispatch import dispatch from multipledispatch import dispatch
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import Message, SysMessage from ovtk_audiencekit.events import Message, SysMessage
from ovtk_audiencekit.events.Message import USER_TYPE from ovtk_audiencekit.events.Message import 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 return parsed_asdict, unknown
class CommandPlugin(PluginBase): class CommandPlugin(PluginBase):
@ -127,9 +127,10 @@ class CommandPlugin(PluginBase):
continue continue
if command.invoked(event): if command.invoked(event):
try: try:
args = command.parse(event.text) args, unknown = command.parse(event.text)
self.logger.debug(f"Parsed args for {command.name}: {args}") self.logger.debug(f"Parsed args for {command.name}: {args}")
ctx = dict(event=event, **args) self.logger.debug(f"Remaining text: {unknown}")
ctx = dict(event=event, rest=' '.join(unknown), **args)
await self.execute_kdl(actionnode.nodes, _ctx=ctx) await self.execute_kdl(actionnode.nodes, _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)
@ -138,7 +139,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)

View file

@ -6,7 +6,7 @@ import uuid
import maya import maya
import aioscheduler import aioscheduler
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.utils import format_exception from ovtk_audiencekit.utils import format_exception
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,6 +1,6 @@
import subprocess import subprocess
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import SysMessage from ovtk_audiencekit.events import SysMessage

View file

@ -1,4 +1,4 @@
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
import logging import logging

View file

@ -0,0 +1,53 @@
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,4 +1,4 @@
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import SysMessage, Message from ovtk_audiencekit.events import SysMessage, Message

View file

@ -6,7 +6,7 @@ import json
import kdl import kdl
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.utils import format_exception from ovtk_audiencekit.utils import format_exception
@ -36,14 +36,14 @@ class ScenePlugin(PluginBase):
self.blueprint.add_url_rule('/<name>/<cmd>', 'api-sceneset', self.ui_setscene) 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) self.blueprint.add_url_rule('/monitor', 'monitor', self.ui_monitor_ws, is_websocket=True)
async def run(self, name, _children=None, _ctx={}, active=None, group=None, immediate=True, oneshot=False, **kwargs): async def run(self, name, _children=None, _ctx={}, active=None, group=None, oneshot=False, **kwargs):
if _children is None and active is None: if _children is None:
raise UsageError('Either define a new scene or set `--active` to true / false') raise UsageError('Empty scene definition! Did you mean scene.set?')
if _children: await self.define(name, group, _children, default_active=active, oneshot=oneshot, ctx=_ctx)
await self.define(name, group, _children, default_active=active, oneshot=oneshot, ctx=_ctx)
else: async def set(self, name, _children=None, _ctx={}, active=True, wait=False):
await self.switch(name, active, is_immediate=immediate, ctx=_ctx) await self.switch(name, active, is_immediate=not wait, ctx=_ctx)
async def define(self, name, group, children, default_active=False, oneshot=False, ctx={}): async def define(self, name, group, children, default_active=False, oneshot=False, ctx={}):
if self.scenes.get(name) is not None: if self.scenes.get(name) is not None:

View file

@ -1,4 +1,4 @@
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.core.Config import compute_dynamic from ovtk_audiencekit.core.Config import compute_dynamic

View file

@ -2,7 +2,7 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
import typing import typing
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import Message from ovtk_audiencekit.events import Message

View file

@ -0,0 +1,9 @@
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 @@
import subprocess
import websockets
from ovtk_audiencekit.plugins import PluginBase
from ovtk_audiencekit.events import SysMessage
from ovtk_audiencekit.utils import make_sync
@make_sync
async def send(ws, data):
async with websockets.connect(ws) as websocket:
await websocket.send(data)
class WebSocketPlugin(PluginBase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def run(self, endpoint, data, _ctx={}, **kwargs):
send(endpoint, data)

View file

@ -1,6 +1,6 @@
import os import os
from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.events import SysMessage, Message from ovtk_audiencekit.events import SysMessage, Message

View file

@ -6,10 +6,13 @@ from .Write import WritePlugin as write
from .Exec import ExecPlugin as exec from .Exec import ExecPlugin as exec
from .Chance import ChancePlugin as chance from .Chance import ChancePlugin as chance
from .Choice import ChoicePlugin as choice from .Choice import ChoicePlugin as choice
from .Midi import MidiPlugin as midi
from .WebSocket import WebSocketPlugin as ws
from .Set import SetPlugin as set from .Set import SetPlugin as set
from .Scene import ScenePlugin as scene from .Scene import ScenePlugin as scene
from .Log import LogPlugin as log from .Log import LogPlugin as log
from .Mkevent import MakeEventPkugin as mkevent
from .Wait import WaitPlugin as wait
__all__ = ['trigger', 'reply', 'command', 'cue', 'write', 'exec', 'chance', 'choice', 'midi', 'ws', 'set', 'scene', 'log'] __all__ = [
'trigger', 'reply', 'command', 'cue', 'write', 'exec', 'chance', 'choice',
'set', 'scene', 'log', 'mkevent', 'wait',
]