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

View file

@ -1,7 +1,7 @@
/* 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 {
/* Generate credentials via https://ovtk.skeh.site/twitch/auth
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:
+ Chats are self-explanitory: the event providers - livestream services.
+ Plugins are the heart of the system, and can both monitor livestream events
and be called by name in your config files to perform actions.
and be called on directly to perform actions.
Some plugins (called "builtins" and always starting with a lowercase character)
are always available, but others are not loaded until you ask to conserve
resources. Chats are never loaded by default.
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 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:otherguy" channel_name="SomeOtherChannel"
// plugin "AudioAlert" output="ALSA:default"
// guest:chat "Twitch" channel_name="CollabChannel" readonly=true
// 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.
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 */
// cue {
// every hours=2 {
/* 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 (arg)"!so {event.from_channel}"
// reply (t)"!so {event.from_channel}"
// }
/* Lurk command */
/* Lurk command (!lurk) */
// command "lurk" help="Lurke modeo" display=true {
// do {
// reply "The Twitch algorithm thanks you for the lurk~" display=true
@ -65,16 +71,57 @@ secrets {
/* TTS for every donation event */
// plugin "TTS"
// trigger monitization="0.01-" source="self" {
// TTS (arg)"{event.user_name} says: {event.text}"
// trigger monitization="0.01-" {
// 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" {
// midi "sysex" data="FunEffect;1"
// cue {
// after minutes=5 {
// midi "sysex" data="FunEffect;0"
// 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
*/

1394
pdm.lock generated

File diff suppressed because it is too large Load diff

View file

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

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('--web-bind', default='localhost')
@click.option('--web-port', default='8000')
def start(config_file, bus_bind=None, bus_port=None, web_bind=None, web_port=None):
@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))
web_conf=(web_bind, web_port),
max_concurrent=parallel)
try:
asyncio.run(main.run())
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)
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__)
if target_event.__dict__.get('_cli'):
command = target_event._cli(command)
return command

View file

@ -17,11 +17,12 @@ import hypercorn
import hypercorn.asyncio
import hypercorn.logging
from ovtk_audiencekit.core import WebsocketServerProcess
from ovtk_audiencekit.core.Config import parse_kdl_deep, kdl_reserved, compute_dynamic
from ovtk_audiencekit.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, PluginError
from ovtk_audiencekit.plugins import builtins
from ovtk_audiencekit.utils import format_exception
logger = logging.getLogger(__name__)
@ -52,11 +53,12 @@ def import_or_reload_mod(module_name, default_package=None, external=False):
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.config_path = config_path
self.bus_conf = bus_conf
self.web_conf = web_conf
self.max_concurrent = max_concurrent
self.chat_processes = {}
self.plugins = {}
@ -129,7 +131,6 @@ class MainProcess:
if e.fatal:
self._unload_plugin(e.source)
except Exception as e:
self._plugin_error
logger.critical(f'Failure when processing {plugin_name} ({e}) - disabling...')
logger.debug(format_exception(e))
self._unload_plugin(plugin_name)
@ -237,7 +238,7 @@ class MainProcess:
if plugin_module is None:
logger.error(f'Unknown plugin: {node.name}')
else:
await plugin_module._call(node.sub, node.tag, *node.args, **node.props, _ctx=global_ctx, _children=node.nodes)
await plugin_module._kdl_call(node, global_ctx)
async def user_shutdown(self):
for process_name, process in list(reversed(self.chat_processes.items())):
@ -293,6 +294,7 @@ class MainProcess:
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')

View file

@ -9,6 +9,8 @@ import kdl
import quart
from ovtk_audiencekit.core.Config import kdl_parse_config, compute_dynamic
from ovtk_audiencekit.utils import format_exception
class PluginError(Exception):
@ -30,6 +32,7 @@ class OvtkBlueprint(quart.Blueprint):
class PluginBase(ABC):
plugins = {}
hooks = {} # the hookerrrrrrrr
def __init__(self, chat_processes, event_queue, name, global_ctx, _children=None, **kwargs):
super().__init__(**kwargs)
@ -56,24 +59,41 @@ class PluginBase(ABC):
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):
args, props = compute_dynamic(node, _ctx=_ctx)
subroutine = node.sub
async def _call(self, subroutine, tag, *args, **kwargs):
try:
if subroutine:
func = self
for accessor in subroutine:
func = getattr(func, accessor)
else:
func = self.run
res = func(*args, **kwargs)
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(*args, _children=node.nodes, _ctx=_ctx, **props)
if asyncio.iscoroutinefunction(func):
res = await res
return res
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)
@ -98,6 +118,7 @@ class PluginBase(ABC):
raise e
raise PluginError(self._name, str(e)) from e
# Base class helpers
def broadcast(self, event):
"""Send event to every active chat"""
@ -106,7 +127,10 @@ class PluginBase(ABC):
continue
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
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})
for node in nodes:
try:
args, props = compute_dynamic(node, _ctx=_ctx)
target = self.plugins.get(node.name)
if target is None:
self.logger.warning(f'Could not find plugin or builtin with name {node.name}')
break
result = await target._call(node.sub, node.tag, *args, *py_args, **props, _ctx=_ctx, **py_props, _children=node.nodes)
if node.alias:
_ctx[node.alias] = result
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
@ -134,6 +156,7 @@ class PluginBase(ABC):
"""
self._event_queue.put_nowait(event)
# User-defined
async def setup(self, *args, **kwargs):
"""Called when plugin is being loaded."""

View file

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

View file

@ -1,20 +1,47 @@
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
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, '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)
@classmethod
def __cli__(cls):
def _cli(cls, cmd):
def dono(ctx, param, value):
if value:
return [value, value]
return [
click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono),
]
cmd.params.append(click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono))
return cmd
@dataclass

View file

@ -28,11 +28,12 @@ class Subscription(Event):
return f"Subcription from {self.user_name or 'anonymous'} to {recipent} - tier = {self.tier}"
@classmethod
def __cli__(cls):
def _cli(cls, cmd):
def userfactory(ctx, param, value):
if value:
return [User(user_name=name, user_id=name) for name in value]
return [
click.Option(['--gifted_to', '-g'], type=click.STRING, callback=userfactory, multiple=True),
]
cmd.params.append(
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
__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
from ovtk_audiencekit.plugins import PluginBase
from ovtk_audiencekit.core import PluginBase
from ovtk_audiencekit.core import Clip, Stream
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.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)
key = f'{path}@{speed}{"X" if keep_pitch else "x"}'
clip = self.clips.get(key, [None, None])[0]
@ -53,12 +53,12 @@ class AudioAlert(PluginBase):
stream_dq.append(stream)
if immediate:
task = asyncio.create_task(stream.aplay())
task.add_done_callback(self.tasks.remove)
self.tasks.add(task)
if wait:
await stream.aplay()
else:
stream.play()
task = asyncio.create_task(stream.aplay())
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
def close(self):
self._cleanup_task.cancel()

View file

@ -7,7 +7,7 @@ import maya
from requests.exceptions import HTTPError
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.plugins.builtins.Command import Command, CommandTypes
from ovtk_audiencekit.events.Message import Message, SysMessage
@ -87,7 +87,7 @@ class JailPlugin(PluginBase):
if isinstance(event, Message):
if self.jail_command.invoked(event):
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')
deets = self.chats[event.via].shared.api.get_user_details(args['username'])
if deets is None:
@ -117,7 +117,7 @@ class JailPlugin(PluginBase):
self.send_to_bus(weewoo)
elif self.unjail_command.invoked(event):
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'])
if deets is None:
raise ValueError()

View file

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

View file

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

View file

@ -4,7 +4,7 @@ import json
import os
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 .Formatter import PhraseCountFormatter

View file

@ -2,7 +2,7 @@ from argparse import ArgumentError
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.events.Message import Message, SysMessage, USER_TYPE
from ovtk_audiencekit.chats.Twitch import Process as Twitch
@ -47,7 +47,7 @@ class ShoutoutPlugin(PluginBase):
if isinstance(event, Message):
if self.command and self.command.invoked(event):
try:
args = self.command.parse(event.text)
args, _ = self.command.parse(event.text)
except ArgumentError as e:
msg = SysMessage(self._name, str(e), replies_to=event)
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.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.core import Clip, Stream
from ovtk_audiencekit.core.Data import CACHE_DIR
@ -19,7 +19,6 @@ class TextToSpeechPlugin(PluginBase):
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:
@ -59,6 +58,12 @@ class TextToSpeechPlugin(PluginBase):
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):
if filename is None:
filename = os.path.join(self.cache_dir, f'{uuid.uuid1()}.wav')
@ -71,25 +76,29 @@ class TextToSpeechPlugin(PluginBase):
self.synthesizer.save_wav(wav, filename)
return filename
async def run(self, text, *args, _ctx={}, wait=True, **kwargs):
async def run(self, text, *args, _ctx={}, wait=False, **kwargs):
try:
# Force punctuation (keep AI from spinning off into random noises)
if not any([text.endswith(punc) for punc in '.!?:']):
text += '.'
filename = self.make_tts_wav(text)
# 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)
if wait:
async def play():
try:
await stream.aplay()
finally:
stream.close()
os.remove(os.path.join(self.cache_dir, filename))
asyncio.create_task(play())
else:
stream.play()
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'):

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import sys
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.Message import USER_TYPE
@ -87,7 +87,7 @@ class Command:
args = emoteless.split()[1:]
parsed, unknown = self._parser.parse_known_args(args)
parsed_asdict = vars(parsed)
return parsed_asdict
return parsed_asdict, unknown
class CommandPlugin(PluginBase):
@ -127,9 +127,10 @@ class CommandPlugin(PluginBase):
continue
if command.invoked(event):
try:
args = command.parse(event.text)
args, unknown = command.parse(event.text)
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)
except argparse.ArgumentError as e:
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):
try:
args = self.help_cmd.parse(event.text)
args, _ = self.help_cmd.parse(event.text)
except argparse.ArgumentError as e:
msg = SysMessage(self._name, f"{e}. See !help {self.help_cmd.name}", replies_to=event)
self.chats[event.via].send(msg)

View file

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

View file

@ -1,6 +1,6 @@
import subprocess
from ovtk_audiencekit.plugins import PluginBase
from ovtk_audiencekit.core import PluginBase
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

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

View file

@ -6,7 +6,7 @@ import json
import kdl
from ovtk_audiencekit.plugins import PluginBase
from ovtk_audiencekit.core import PluginBase
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('/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):
if _children is None and active is None:
raise UsageError('Either define a new scene or set `--active` to true / false')
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?')
if _children:
await self.define(name, group, _children, default_active=active, oneshot=oneshot, ctx=_ctx)
else:
await self.switch(name, active, is_immediate=immediate, 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:

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

View file

@ -2,7 +2,7 @@ import re
from dataclasses import dataclass
import typing
from ovtk_audiencekit.plugins import PluginBase
from ovtk_audiencekit.core import PluginBase
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
from ovtk_audiencekit.plugins import PluginBase
from ovtk_audiencekit.core import PluginBase
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 .Chance import ChancePlugin as chance
from .Choice import ChoicePlugin as choice
from .Midi import MidiPlugin as midi
from .WebSocket import WebSocketPlugin as ws
from .Set import SetPlugin as set
from .Scene import ScenePlugin as scene
from .Log import LogPlugin as log
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',
]