Compare commits

..

No commits in common. "4d85ecb5d2c0d866395ff5c5dacf8bdeadb5b643" and "240b245154d23a9cf04b968d0862141c70015bf2" have entirely different histories.

13 changed files with 40 additions and 157 deletions

View file

@ -15,14 +15,12 @@ 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')
@click.option('--parallel', default=5) def start(config_file, bus_bind=None, bus_port=None, web_bind=None, web_port=None):
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,11 +76,10 @@ 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

@ -53,12 +53,11 @@ def import_or_reload_mod(module_name, default_package=None, external=False):
class MainProcess: class MainProcess:
def __init__(self, config_path, bus_conf, web_conf, max_concurrent=5): def __init__(self, config_path, bus_conf=(None, None), web_conf=(None, None)):
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 = {}
@ -295,8 +294,7 @@ 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()))
for i in range(0, self.max_concurrent): user_tasks.add(loop.create_task(self.handle_events()))
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

@ -1,47 +1,20 @@
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 = None target: str
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, **self.to_dict()}, 'data': {**self.data, 'target': self.target},
}) })
@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,13 +43,14 @@ class Message(Event):
return super().hydrate(user_type=user_type, **kwargs) return super().hydrate(user_type=user_type, **kwargs)
@classmethod @classmethod
def _cli(cls, cmd): def __cli__(cls):
def dono(ctx, param, value): def dono(ctx, param, value):
if value: if value:
return [value, value] return [value, value]
cmd.params.append(click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono)) return [
return cmd click.Option(['--monitization', '-m'], type=click.FLOAT, callback=dono),
]
@dataclass @dataclass

View file

@ -28,12 +28,11 @@ 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, cmd): def __cli__(cls):
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]
cmd.params.append( return [
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

@ -22,16 +22,14 @@ class AudioAlert(PluginBase):
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:
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
async def run(self, path, speed=1, keep_pitch=False, wait=False, poly=1, **kwargs): def run(self, path, speed=1, keep_pitch=False, immediate=True, 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]
if clip is None: if clip is None:
clip = Clip(path, speed=speed, keep_pitch=keep_pitch, clip = Clip(path, speed=speed, keep_pitch=keep_pitch, force_stereo=self.force_stereo)
samplerate=self.sample_rate, force_stereo=self.force_stereo)
self.clips[key] = [clip, maya.now()] self.clips[key] = [clip, maya.now()]
else: else:
self.clips[key][1] = maya.now() self.clips[key][1] = maya.now()
@ -53,12 +51,12 @@ class AudioAlert(PluginBase):
stream_dq.append(stream) stream_dq.append(stream)
if wait: if immediate:
await stream.aplay()
else:
task = asyncio.create_task(stream.aplay()) task = asyncio.create_task(stream.aplay())
task.add_done_callback(self.tasks.discard) task.add_done_callback(self.tasks.remove)
self.tasks.add(task) self.tasks.add(task)
else:
stream.play()
def close(self): def close(self):
self._cleanup_task.cancel() self._cleanup_task.cancel()

View file

@ -19,11 +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)
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_')} conf_overrides = {k[2:]: v for k, v in kwargs.items() if k.startswith('o_')}
@ -58,12 +53,6 @@ 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')
@ -76,29 +65,25 @@ 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=False, **kwargs): async def run(self, text, *args, _ctx={}, wait=True, **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 += '.'
# Do TTS processing in a thread to avoid blocking main loop filename = self.make_tts_wav(text)
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)
stream = Stream(clip, self.output_index) stream = Stream(clip, self.output_index)
async def play(): if wait:
try: async def play():
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))
task = asyncio.create_task(play()) asyncio.create_task(play())
self.tasks.add(task) else:
task.add_done_callback(self.tasks.discard) stream.play()
stream.close()
if wait: os.remove(os.path.join(self.cache_dir, filename))
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

@ -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):
@ -127,10 +127,9 @@ 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)
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)

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

@ -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, oneshot=False, **kwargs): async def run(self, name, _children=None, _ctx={}, active=None, group=None, immediate=True, oneshot=False, **kwargs):
if _children is None: if _children is None:
raise UsageError('Empty scene definition! Did you mean scene.set?') raise UsageError('Empty scene definition! Did you mean scene.set?')
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)
async def set(self, name, _children=None, _ctx={}, active=True, wait=False): async def set(self, name, _children=None, _ctx={}, active=True, immediate=True):
await self.switch(name, active, is_immediate=not wait, ctx=_ctx) await self.switch(name, active, is_immediate=immediate, 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,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

@ -9,10 +9,5 @@ from .Choice import ChoicePlugin as choice
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__ = [ __all__ = ['trigger', 'reply', 'command', 'cue', 'write', 'exec', 'chance', 'choice', 'set', 'scene', 'log']
'trigger', 'reply', 'command', 'cue', 'write', 'exec', 'chance', 'choice',
'set', 'scene', 'log', 'mkevent', 'wait',
]