diff --git a/src/ovtk_audiencekit/chats/Twitch/TwitchProcess.py b/src/ovtk_audiencekit/chats/Twitch/TwitchProcess.py index f66734b..4dde8eb 100644 --- a/src/ovtk_audiencekit/chats/Twitch/TwitchProcess.py +++ b/src/ovtk_audiencekit/chats/Twitch/TwitchProcess.py @@ -27,6 +27,8 @@ class TwitchProcess(ChatProcess): botname=None, emote_res=4.0, # EventSub options eventsub=True, eventsub_host='wss://ovtk.skeh.site/twitch', + # BTTV integration + bttv=False, # Inheritance boilerplate **kwargs): super().__init__(*args, **kwargs) @@ -61,7 +63,7 @@ class TwitchProcess(ChatProcess): self.eventsub = TwitchEventSub(self.api, eventsub_host) self._sources.append(self.eventsub) - self.bttv = BTTV(target_data['user']['id']) + self.bttv = BTTV(target_data['user']['id']) if bttv else None def loop(self, next_state): @@ -98,7 +100,7 @@ class TwitchProcess(ChatProcess): for event in chain(*(source.read(0.1) for source in self._sources)): # Retarget event event.via = self._name - if isinstance(event, Message): + if self.bttv and isinstance(event, Message): event = self.bttv.hydrate(event) self.publish(event) return 0 diff --git a/src/ovtk_audiencekit/core/Clip.py b/src/ovtk_audiencekit/core/Audio.py similarity index 51% rename from src/ovtk_audiencekit/core/Clip.py rename to src/ovtk_audiencekit/core/Audio.py index 78d3f68..e82c522 100644 --- a/src/ovtk_audiencekit/core/Clip.py +++ b/src/ovtk_audiencekit/core/Audio.py @@ -25,71 +25,62 @@ os.close(old_stderr) logger = logging.getLogger(__name__) - -def check_rate(index, channels, rate): - try: - return pyaudio.is_format_supported(rate, - output_channels=channels, - output_device=index, - output_format=pya.paFloat32) - except ValueError: - return False - -alt_rates = [44100, 48000] class Clip: - def __init__(self, path, output_index, buffer_length=2048, speed=1, force_stereo=True): - _raw, native_rate = librosa.load(path, sr=None, dtype='float32', mono=False) - self._channels = _raw.shape[0] if len(_raw.shape) == 2 else 1 - if force_stereo and self._channels == 1: - _raw = np.resize(_raw, (2,*_raw.shape)) - self._channels = 2 + 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) - target_samplerate = native_rate - if not check_rate(output_index, self._channels , native_rate): - try: - target_samplerate = next((rate for rate in alt_rates if check_rate(output_index, self._channels , rate))) - except StopIteration: - logger.warn('Target audio device does not claim to support any sample rates! Attempting playback at native rate') - self._samplerate = target_samplerate + 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 - if native_rate != self._samplerate: - _raw = librosa.resample(_raw, native_rate, self._samplerate, fix=True, scale=True) + 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') + self.raw = np.ascontiguousarray(self._stereo_transpose(raw), dtype='float32') if speed != 1: - self.stretch(speed) + self.stretch(speed, keep_pitch=keep_pitch) - self._pos = 0 - self._playing = False + @property + def length(self): + return self.raw.shape[0] / self.samplerate + + def _stereo_transpose(self, ndata): + return ndata if self.channels == 1 else ndata.T + + def stretch(self, speed, keep_pitch=True): + if keep_pitch: + stretched = tsm.wsola(self._stereo_transpose(self.raw), speed) + else: + stretched = librosa.resample(self._stereo_transpose(self.raw), self.samplerate * (1 / 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._channels, - rate=self._samplerate, + channels=self.clip.channels, + rate=self.clip.samplerate, frames_per_buffer=buffer_length, output=True, stream_callback=self._read_callback, start=False) - @property - def length(self): - return self._raw.shape[0] / self._samplerate - - def _stereo_transpose(self, ndata): - return ndata if self._channels == 1 else ndata.T - - def stretch(self, speed): - stretched = tsm.wsola(self._stereo_transpose(self._raw), speed) - self._raw = np.ascontiguousarray(self._stereo_transpose(stretched), dtype='float32') - - def save(self, filename): - soundfile.write(filename, self._stereo_transpose(self._raw), self._samplerate) - def _play(self): - self._playing = True - self._pos = 0 + self.playing = True + self.pos = 0 if not self._stream.is_active(): self._stream.start_stream() @@ -97,38 +88,49 @@ class Clip: def play(self): self._end_event.clear() self._play() - self._end_event.wait(timeout=self.length) + 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.length) + await self._end_event.coro_wait(timeout=self.clip.length) except asyncio.CancelledError: - self._playing = False + 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._channels > 1: - buffer = np.zeros((frame_count, self._channels), dtype='float32') + 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._raw[self._pos:newpos] - self._pos = newpos + 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._raw.shape[0]: - self._playing = False + 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: diff --git a/src/ovtk_audiencekit/core/MainProcess.py b/src/ovtk_audiencekit/core/MainProcess.py index 863fd7c..9b9d692 100644 --- a/src/ovtk_audiencekit/core/MainProcess.py +++ b/src/ovtk_audiencekit/core/MainProcess.py @@ -219,9 +219,9 @@ class MainProcess: plugin_module = import_or_reload_mod(module_name, default_package='ovtk_audiencekit.plugins', external=False) - plugin = plugin_module.Plugin(self.chat_processes, self.event_queue, plugin_name, global_ctx, - **node.props, **secrets_for_mod, _children=node.nodes) + 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: diff --git a/src/ovtk_audiencekit/core/PluginBase.py b/src/ovtk_audiencekit/core/PluginBase.py index e16cafd..cd814b3 100644 --- a/src/ovtk_audiencekit/core/PluginBase.py +++ b/src/ovtk_audiencekit/core/PluginBase.py @@ -86,6 +86,18 @@ class PluginBase(ABC): 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""" @@ -123,6 +135,10 @@ class PluginBase(ABC): 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 @@ -143,7 +159,7 @@ class PluginBase(ABC): pass @abstractmethod - async def run(self, _children=None, _ctx={}, **kwargs): + 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 """ diff --git a/src/ovtk_audiencekit/core/__init__.py b/src/ovtk_audiencekit/core/__init__.py index 17c86a4..5de5ce3 100644 --- a/src/ovtk_audiencekit/core/__init__.py +++ b/src/ovtk_audiencekit/core/__init__.py @@ -1,3 +1,3 @@ from .WebsocketServerProcess import WebsocketServerProcess from .MainProcess import MainProcess -from .Clip import Clip +from .Audio import Clip, Stream diff --git a/src/ovtk_audiencekit/plugins/AudioAlert/AudioAlert.py b/src/ovtk_audiencekit/plugins/AudioAlert/AudioAlert.py index dd48ab8..5726803 100644 --- a/src/ovtk_audiencekit/plugins/AudioAlert/AudioAlert.py +++ b/src/ovtk_audiencekit/plugins/AudioAlert/AudioAlert.py @@ -1,27 +1,80 @@ import asyncio +from collections import deque + +import maya from ovtk_audiencekit.plugins import PluginBase -from ovtk_audiencekit.core import Clip +from ovtk_audiencekit.core import Clip, Stream class AudioAlert(PluginBase): - def __init__(self, *args, output=None, buffer_length=2048, cutoff_prevention_buffers=None, **kwargs): - super().__init__(*args, **kwargs) - if cutoff_prevention_buffers: - self.logger.info('`cutoff_prevention_buffers` are depricated') + def setup(self, output=None, timeout_min=1, sample_rate=None, buffer_length=4096, force_stereo=True): + 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.sounds = {} - self._buffer_length = int(buffer_length) - self._output_index = Clip.find_output_index(output) + self._cleanup_task = asyncio.create_task(self._cleanup()) + + def run(self, path, speed=1, keep_pitch=False, immediate=True, 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, 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) - def run(self, path, speed=1, immediate=True, **kwargs): - if self.sounds.get(path) is None: - self.sounds[path] = Clip(path, - self._output_index, - buffer_length=self._buffer_length, - speed=speed) - sound = self.sounds.get(path) if immediate: - asyncio.create_task(sound.aplay()) + task = asyncio.create_task(stream.aplay()) + task.add_done_callback(self.tasks.remove) + self.tasks.add(task) else: - sound.play() + stream.play() + + 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] diff --git a/src/ovtk_audiencekit/plugins/Jail/JailPlugin.py b/src/ovtk_audiencekit/plugins/Jail/JailPlugin.py index d82b5c8..9426054 100644 --- a/src/ovtk_audiencekit/plugins/Jail/JailPlugin.py +++ b/src/ovtk_audiencekit/plugins/Jail/JailPlugin.py @@ -35,8 +35,7 @@ owomap = { } class JailPlugin(PluginBase): - def __init__(self, *args, min_level='vip', persist=True, **kwargs): - super().__init__(*args, **kwargs) + def setup(self, min_level='vip', persist=True): self.persist = persist self._cache = os.path.join(CACHE_DIR, 'Jail', 'sentences') os.makedirs(os.path.dirname(self._cache), exist_ok=True) diff --git a/src/ovtk_audiencekit/plugins/OBS/obs.py b/src/ovtk_audiencekit/plugins/OBS/obs.py index 644bbe2..3c56fd9 100644 --- a/src/ovtk_audiencekit/plugins/OBS/obs.py +++ b/src/ovtk_audiencekit/plugins/OBS/obs.py @@ -6,14 +6,10 @@ from ovtk_audiencekit.plugins import PluginBase class OBSWSPlugin(PluginBase): - def __init__(self, *args, password=None, uri='ws://localhost:4455', **kwargs): - super().__init__(*args, **kwargs) + async def setup(self, password=None, uri='ws://localhost:4455'): self.uri = uri self.obsws = simpleobsws.WebSocketClient(url=uri, password=password) - asyncio.get_event_loop().run_until_complete(self.setup()) - - async def setup(self): await self.obsws.connect() success = await self.obsws.wait_until_identified() if not success: diff --git a/src/ovtk_audiencekit/plugins/OSC/osc.py b/src/ovtk_audiencekit/plugins/OSC/osc.py index fc06234..0416b79 100644 --- a/src/ovtk_audiencekit/plugins/OSC/osc.py +++ b/src/ovtk_audiencekit/plugins/OSC/osc.py @@ -4,8 +4,7 @@ from ovtk_audiencekit.plugins import PluginBase class OSCPlugin(PluginBase): - def __init__(self, *args, ip='localhost', port=None, **kwargs): - super().__init__(*args, **kwargs) + 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)) diff --git a/src/ovtk_audiencekit/plugins/PhraseCounter/PhraseCounter.py b/src/ovtk_audiencekit/plugins/PhraseCounter/PhraseCounter.py index 0a9fec2..0868b00 100644 --- a/src/ovtk_audiencekit/plugins/PhraseCounter/PhraseCounter.py +++ b/src/ovtk_audiencekit/plugins/PhraseCounter/PhraseCounter.py @@ -64,8 +64,7 @@ class PhraseCounter: class PhraseCounterPlugin(PluginBase): - def __init__(self, *args, debounce_time=1, persist=False, **kwargs): - super().__init__(*args, **kwargs) + def setup(self, debounce_time=1, persist=False): self.debounce_time = debounce_time self.persist = persist diff --git a/src/ovtk_audiencekit/plugins/Shoutout/ShoutoutPlugin.py b/src/ovtk_audiencekit/plugins/Shoutout/ShoutoutPlugin.py index 0e20c43..61afd6e 100644 --- a/src/ovtk_audiencekit/plugins/Shoutout/ShoutoutPlugin.py +++ b/src/ovtk_audiencekit/plugins/Shoutout/ShoutoutPlugin.py @@ -9,10 +9,8 @@ from ovtk_audiencekit.chats.Twitch import Process as Twitch class ShoutoutPlugin(PluginBase): - def __init__(self, *args, command='so', min_level='vip', - text='Check out {link}!~ They were last streaming {last_game}', - **kwargs): - super().__init__(*args, **kwargs) + def setup(self, command='so', min_level='vip', + text='Check out {link}!~ They were last streaming {last_game}'): self.text = text if command: self.command = Command(name=command, help='Shoutout another user', required_level=min_level) diff --git a/src/ovtk_audiencekit/plugins/TTS/TTS.py b/src/ovtk_audiencekit/plugins/TTS/TTS.py index 124c61b..328cb68 100644 --- a/src/ovtk_audiencekit/plugins/TTS/TTS.py +++ b/src/ovtk_audiencekit/plugins/TTS/TTS.py @@ -8,24 +8,22 @@ from TTS.config import load_config from ovtk_audiencekit.plugins import PluginBase from ovtk_audiencekit.events import Message, SysMessage -from ovtk_audiencekit.core import Clip +from ovtk_audiencekit.core import Clip, Stream from ovtk_audiencekit.core.Data import CACHE_DIR class TextToSpeechPlugin(PluginBase): - def __init__(self, *args, output=None, cuda=None, - engine="tts_models/en/ljspeech/tacotron2-DDC", speaker_wav=None, - _children=None, **kwargs): - super().__init__(*args, _children=_children) + 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 = Clip.find_output_index(output) + self.output_index = Stream.find_output_index(output) conf_overrides = {k[2:]: v for k, v in kwargs.items() if k.startswith('o_')} - self._cache = os.path.join(CACHE_DIR, 'tts') - os.makedirs(os.path.dirname(self._cache), exist_ok=True) + self.cache_dir = os.path.join(CACHE_DIR, 'tts') + os.makedirs(os.path.dirname(self.cache_dir), exist_ok=True) self.cuda = cuda @@ -38,7 +36,7 @@ class TextToSpeechPlugin(PluginBase): vocoder_path, vocoder_config_path = None, None if conf_overrides: - override_conf_path = os.path.join(self._cache, f'{self._name}_override.json') + 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(): @@ -57,7 +55,7 @@ class TextToSpeechPlugin(PluginBase): def make_tts_wav(self, text, filename=None): if filename is None: - filename = os.path.join(self._cache, f'{uuid.uuid1()}.wav') + filename = os.path.join(self.cache_dir, f'{uuid.uuid1()}.wav') if self.speaker_wav: wav = self.synthesizer.tts(text, None, 'en', self.speaker_wav) @@ -74,17 +72,20 @@ class TextToSpeechPlugin(PluginBase): text += '.' filename = self.make_tts_wav(text) # TODO: Play direct from memory - clip = Clip(filename, self._output_index, force_stereo=False) + clip = Clip(filename, force_stereo=True) + stream = Stream(clip, self.output_index) if wait: async def play(): - await clip.aplay() - clip.close() + await stream.aplay() + stream.close() + os.remove(os.path.join(self.cache_dir, filename)) asyncio.create_task(play()) else: - clip.play() - clip.close() + stream.play() + stream.close() + os.remove(os.path.join(self.cache_dir, filename)) except Exception as e: - print(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!!') diff --git a/src/ovtk_audiencekit/plugins/builtins/Chance.py b/src/ovtk_audiencekit/plugins/builtins/Chance.py index a733b9f..5553fee 100644 --- a/src/ovtk_audiencekit/plugins/builtins/Chance.py +++ b/src/ovtk_audiencekit/plugins/builtins/Chance.py @@ -16,4 +16,7 @@ class ChancePlugin(PluginBase): raise ValueError('Chance must be a string (optionally ending in %) or number') if random.random() < chance / 100: - await self.execute_kdl(_children, _ctx=_ctx) + await self.execute_kdl([child for child in _children if child.name != 'or'], _ctx=_ctx) + else: + if elsenode := next((child for child in _children if child.name == 'or'), None): + await self.execute_kdl(elsenode.nodes, _ctx=_ctx) diff --git a/src/ovtk_audiencekit/plugins/builtins/Cue.py b/src/ovtk_audiencekit/plugins/builtins/Cue.py index ef3ebf9..75bfe01 100644 --- a/src/ovtk_audiencekit/plugins/builtins/Cue.py +++ b/src/ovtk_audiencekit/plugins/builtins/Cue.py @@ -31,7 +31,7 @@ class Cue: def is_obsolete(self): if self.repeat: return False - if self._next <= maya.now(): + if self._next >= maya.now(): return False return True @@ -74,7 +74,7 @@ class CuePlugin(PluginBase): cue = Cue(repeat, **kwargs) async def handler(): - # Repetion management + # Repetition management if not cue.enabled: return if cue.repeat: @@ -111,10 +111,16 @@ class CuePlugin(PluginBase): 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 self.cues.items(): + for name, (cue, _) in list(self.cues.items()): if cue.is_obsolete(): del self.cues[name] if task := self.tasks.get(name): diff --git a/src/ovtk_audiencekit/plugins/builtins/Scene/Plugin.py b/src/ovtk_audiencekit/plugins/builtins/Scene/Plugin.py index 23119af..21b56c2 100644 --- a/src/ovtk_audiencekit/plugins/builtins/Scene/Plugin.py +++ b/src/ovtk_audiencekit/plugins/builtins/Scene/Plugin.py @@ -14,6 +14,7 @@ from ovtk_audiencekit.utils import format_exception class Scene: name: str group: str + oneshot: bool enter: Callable exit: Callable entry_context: dict = field(default_factory=dict) @@ -35,16 +36,16 @@ class ScenePlugin(PluginBase): self.blueprint.add_url_rule('//', 'api-sceneset', self.ui_setscene) self.blueprint.add_url_rule('/monitor', 'monitor', self.ui_monitor_ws, is_websocket=True) - async def run(self, name, _children=None, _ctx={}, active=None, group=None, immediate=True, **kwargs): + 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') if _children: - await self.define(name, group, _children, default_active=active, ctx=_ctx) + 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 define(self, name, group, children, default_active=False, 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!') @@ -63,7 +64,7 @@ class ScenePlugin(PluginBase): async def exit(ctx): await self.execute_kdl(exit_nodes, _ctx=ctx) - scene = Scene(name, group, enter, exit) + scene = Scene(name, group, oneshot, enter, exit) self.scenes[name] = scene if default_active: @@ -74,7 +75,9 @@ class ScenePlugin(PluginBase): if scene is None: raise UsageError(f'No defined scene with name "{name}"') - if active: + if scene.oneshot: + await self._execute(scene, 'enter', is_immediate, ctx) + elif active: if current := self.active.get(scene.group): if current == scene: return diff --git a/src/ovtk_audiencekit/plugins/builtins/Scene/templates/index.html b/src/ovtk_audiencekit/plugins/builtins/Scene/templates/index.html index 5e2de71..10f10f0 100644 --- a/src/ovtk_audiencekit/plugins/builtins/Scene/templates/index.html +++ b/src/ovtk_audiencekit/plugins/builtins/Scene/templates/index.html @@ -24,8 +24,10 @@ }) }) const toggle = async (group_name, scene_name) => { - if (inflight.value.includes(scene_name)) return - inflight.value.push(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' }) }