From c1a6bbed97fe6a0ff624313e3758e30cfbe281ae Mon Sep 17 00:00:00 2001 From: onyx-and-iris Date: Wed, 5 Jul 2023 13:44:21 +0100 Subject: [PATCH] initial commit --- .gitignore | 170 ++++++++++++++++++++++ LICENSE.txt | 9 ++ README.md | 64 +++++++++ __main__.py | 73 ++++++++++ configs/potato/streaming.toml | 235 +++++++++++++++++++++++++++++++ duckypad_twitch/__about__.py | 4 + duckypad_twitch/__init__.py | 5 + duckypad_twitch/audio.py | 132 +++++++++++++++++ duckypad_twitch/configuration.py | 20 +++ duckypad_twitch/duckypad.py | 55 ++++++++ duckypad_twitch/layer.py | 25 ++++ duckypad_twitch/obsws.py | 97 +++++++++++++ duckypad_twitch/scene.py | 80 +++++++++++ duckypad_twitch/states.py | 36 +++++ duckypad_twitch/streamlabs.py | 127 +++++++++++++++++ duckypad_twitch/util.py | 33 +++++ pyproject.toml | 146 +++++++++++++++++++ tests/__init__.py | 3 + 18 files changed, 1314 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 __main__.py create mode 100644 configs/potato/streaming.toml create mode 100644 duckypad_twitch/__about__.py create mode 100644 duckypad_twitch/__init__.py create mode 100644 duckypad_twitch/audio.py create mode 100644 duckypad_twitch/configuration.py create mode 100644 duckypad_twitch/duckypad.py create mode 100644 duckypad_twitch/layer.py create mode 100644 duckypad_twitch/obsws.py create mode 100644 duckypad_twitch/scene.py create mode 100644 duckypad_twitch/states.py create mode 100644 duckypad_twitch/streamlabs.py create mode 100644 duckypad_twitch/util.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49d1e87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,170 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.hatch + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# configs +configs/* +!configs/potato + +# test +quick.py + +duckypad-twitch.ps1 \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..59ab6b3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023-present onyx-and-iris <75868496+onyx-and-iris@users.noreply.github.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4deb418 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# duckypad twitch + +[![PyPI - Version](https://img.shields.io/pypi/v/duckypad-twitch.svg)](https://pypi.org/project/duckypad-twitch) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/duckypad-twitch.svg)](https://pypi.org/project/duckypad-twitch) + +--- + +**Table of Contents** + +- [Installation](#installation) +- [License](#license) + +## Installation + +```console +pip install duckypad-twitch +``` + +## About + +This respository holds the source code for the [Duckypad][duckypad] driver we use when Twitch streaming. + +Packages used in this codebase: + +- [`keyboard`][keyboard] +- [`voicemeeter-api`][voicemeeter-api] +- [`vban-cmd`][vban-cmd] +- [`xair-api`][xair-api] +- [`obsws-python`][obsws-python] +- [`slobs-websocket`][slobs-websocket] + +## Need for a custom driver + +We use a three pc streaming setup, one gaming pc for each of us and a third pc that handles the stream. Both of our microphones, as well as both gaming pc are wired into an [MR18 mixer](https://www.midasconsoles.com/product.html?modelCode=P0C8H) which itself is connected to the streaming pc. Then we vban our microphones from the workstation off to each of our pcs in order to talk in-game. All audio is routed through [Voicemeeter][voicemeeter], which itself is connected to Studio ONE daw for background noise removal. Any voice communication software (such as Discord) is therefore installed onto the workstation, separate of our gaming pcs. + +If you've ever attempted to setup a dual pc streaming setup, you may appreciate the audio challenges of a three pc setup. + +## Details about the code + +This is a tightly coupled implementation meaning it is not designed for public use, it is purely a demonstration. + +- All keybindings are defined in `__main__.py`. +- A base DuckyPad class in duckypad.py is used to connect the various layers of the driver. +- Most of the audio routing for the dual stream is handled in the `Audio class` in audio.py with the aid of Voicemeeter's Remote API. + - Some communication with the Xair mixer and the vban protocol can also be found in this class. +- Scene switching and some audio routing are handled in the `Scene class` in scene.py. + - A `StreamlabsController` class is used to communicate with the Streamlabs API. +- Dataclasses are used to hold internal states and states are updated using event callbacks. +- Decorators are used to confirm websocket connections. +- A separate OBSWS class is used to handle scenes and mic muting (for a single pc stream). +- Logging is included to help with debugging but also to provide stream information in real time. + +## License + +`duckypad-twitch` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. + +[duckypad]: https://github.com/dekuNukem/duckyPad +[keyboard]: https://github.com/boppreh/keyboard +[voicemeeter-api]: https://github.com/onyx-and-iris/voicemeeter-api-python +[vban-cmd]: https://github.com/onyx-and-iris/vban-cmd-python +[xair-api]: https://github.com/onyx-and-iris/xair-api-python +[obsws-python]: https://github.com/aatikturk/obsws-python +[slobs-websocket]: https://github.com/onyx-and-iris/slobs_websocket +[voicemeeter]: https://voicemeeter.com/ diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..621ed4a --- /dev/null +++ b/__main__.py @@ -0,0 +1,73 @@ +import logging + +import keyboard +import voicemeeterlib +import xair_api +from slobs_websocket import StreamlabsOBS + +import duckypad_twitch +from duckypad_twitch import configuration + +logging.basicConfig(level=logging.DEBUG) + + +def register_hotkeys(duckypad): + def audio_hotkeys(): + keyboard.add_hotkey("F13", duckypad.audio.mute_mics) + keyboard.add_hotkey("F14", duckypad.audio.only_discord) + keyboard.add_hotkey("F15", duckypad.audio.only_stream) + keyboard.add_hotkey("F16", duckypad.audio.sound_test) + keyboard.add_hotkey("F17", duckypad.audio.solo_onyx) + keyboard.add_hotkey("F18", duckypad.audio.solo_iris) + keyboard.add_hotkey("F19", duckypad.audio.toggle_workstation_to_onyx) + + def scene_hotkeys(): + keyboard.add_hotkey("ctrl+F13", duckypad.scene.onyx_only) + keyboard.add_hotkey("ctrl+F14", duckypad.scene.iris_only) + keyboard.add_hotkey("ctrl+F15", duckypad.scene.dual_scene) + keyboard.add_hotkey("ctrl+F16", duckypad.scene.onyx_big) + keyboard.add_hotkey("ctrl+F17", duckypad.scene.iris_big) + keyboard.add_hotkey("ctrl+F18", duckypad.scene.start) + keyboard.add_hotkey("ctrl+F19", duckypad.scene.brb) + keyboard.add_hotkey("ctrl+F20", duckypad.scene.end) + + def obsws_hotkeys(): + keyboard.add_hotkey("ctrl+alt+F13", duckypad.obsws.start) + keyboard.add_hotkey("ctrl+alt+F14", duckypad.obsws.brb) + keyboard.add_hotkey("ctrl+alt+F15", duckypad.obsws.end) + keyboard.add_hotkey("ctrl+alt+F16", duckypad.obsws.live) + keyboard.add_hotkey("ctrl+alt+F17", duckypad.obsws.toggle_mute_mic) + keyboard.add_hotkey("ctrl+alt+F18", duckypad.obsws.toggle_stream) + + [step() for step in (audio_hotkeys, scene_hotkeys, obsws_hotkeys)] + + +def main(): + xair_config = configuration.get("xair") + + with voicemeeterlib.api("potato") as vm: + with xair_api.connect("MR18", **xair_config) as mixer: + sl = StreamlabsOBS() + + duckypad = duckypad_twitch.connect(vm=vm, mixer=mixer, sl=sl) + + vm.apply_config("streaming") + + register_hotkeys(duckypad) + + keyboard.add_hotkey("ctrl+F21", duckypad.reset) + keyboard.add_hotkey("ctrl+F22", duckypad.streamlabs_controller.begin_stream) + keyboard.add_hotkey("ctrl+F23", duckypad.streamlabs_controller.end_stream) + keyboard.add_hotkey( + "ctrl+alt+F23", duckypad.streamlabs_controller.launch, args=(5,) + ) + keyboard.add_hotkey("ctrl+alt+F24", duckypad.streamlabs_controller.shutdown) + + print("press ctrl+m to quit") + keyboard.wait("ctrl+m") + + sl.disconnect() + + +if __name__ == "__main__": + main() diff --git a/configs/potato/streaming.toml b/configs/potato/streaming.toml new file mode 100644 index 0000000..dc128f3 --- /dev/null +++ b/configs/potato/streaming.toml @@ -0,0 +1,235 @@ +[strip-0] +label = "Onyx mic" +A1 = false +A2 = false +A3 = false +A4 = false +A5 = false +B1 = true +B2 = false +B3 = false +mono = false +solo = false +mute = true +gain = 0.0 +limit = 0 +comp.knob = 0 +gate.knob = 0 + +[strip-1] +label = "Iris mic" +A1 = false +A2 = false +A3 = false +A4 = false +A5 = false +B1 = false +B2 = true +B3 = false +mono = false +solo = false +mute = true +gain = 0.0 +limit = 0 +comp.knob = 0 +gate.knob = 0 + +[strip-2] +label = "Onyx Pc" +A1 = false +A2 = false +A3 = false +A4 = false +A5 = true +B1 = false +B2 = false +B3 = false +mono = false +solo = false +mute = true +gain = 0.0 +limit = 0 +comp.knob = 0 +gate.knob = 0 + +[strip-3] +label = "Iris Pc" +A1 = false +A2 = false +A3 = false +A4 = false +A5 = true +B1 = false +B2 = false +B3 = false +mono = false +solo = false +mute = true +gain = 0.0 +limit = 0 +comp.knob = 0 +gate.knob = 0 + +[strip-4] +label = "Mics to Stream" +A1 = false +A2 = false +A3 = false +A4 = false +A5 = false +B1 = false +B2 = false +B3 = true +mono = false +solo = false +mute = true +gain = 0.0 +limit = 0 +comp.knob = 0 +gate.knob = 0 + +[strip-5] +label = "System" +A1 = false +A2 = true +A3 = false +A4 = false +A5 = false +B1 = false +B2 = false +B3 = false +mono = false +solo = false +mute = false +gain = 0.0 +limit = 0 + +[strip-6] +label = "Comms" +A1 = false +A2 = false +A3 = true +A4 = false +A5 = false +B1 = false +B2 = false +B3 = false +mono = false +solo = false +mute = false +gain = 0.0 +limit = 0 +k = 0 + +[strip-7] +label = "Pretzel" +A1 = false +A2 = false +A3 = false +A4 = true +A5 = false +B1 = false +B2 = false +B3 = false +mono = false +solo = false +mute = false +gain = 0.0 +limit = 0 + +[bus-0] +label = "MR18" +mono = false +eq.on = false +mute = false +gain = 0.0 +mode = "normal" + +[bus-1] +label = "ASIO [1,2]" +mono = false +eq.on = false +mute = false +gain = 0.0 +mode = "normal" + +[bus-2] +label = "ASIO [3,4]" +mono = false +eq.on = false +mute = false +gain = 0.0 +mode = "normal" + +[bus-3] +label = "ASIO [5,6]" +mono = false +eq.on = false +mute = false +gain = 0.0 +mode = "normal" + +[bus-4] +label = "ASIO [7,8]" +mono = false +eq.on = false +mute = false +gain = 0.0 +mode = "normal" + +[bus-5] +label = "Onyx Mic" +mono = false +eq.on = false +mute = true +gain = 0.0 +mode = "normal" + +[bus-6] +label = "Iris Mic" +mono = false +eq.on = false +mute = true +gain = 0.0 +mode = "normal" + +[bus-7] +label = "Both Mics" +mono = false +eq.on = false +mute = false +gain = 0.0 +mode = "normal" + +[button-0] +stateonly = true + +[button-1] +stateonly = false + +[button-2] +stateonly = true + +[vban-out-0] +on = false + +[vban-out-1] +on = false + +[vban-out-2] +on = false + +[vban-out-3] +on = false + +[vban-out-4] +on = false + +[vban-out-5] +on = false + +[vban-out-6] +on = false + +[vban-out-7] +on = false diff --git a/duckypad_twitch/__about__.py b/duckypad_twitch/__about__.py new file mode 100644 index 0000000..06b03cb --- /dev/null +++ b/duckypad_twitch/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2023-present onyx-and-iris <75868496+onyx-and-iris@users.noreply.github.com> +# +# SPDX-License-Identifier: MIT +__version__ = "1.0.0" diff --git a/duckypad_twitch/__init__.py b/duckypad_twitch/__init__.py new file mode 100644 index 0000000..35dc75d --- /dev/null +++ b/duckypad_twitch/__init__.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2023-present onyx-and-iris <75868496+onyx-and-iris@users.noreply.github.com> +# +# SPDX-License-Identifier: MIT + +from .duckypad import connect diff --git a/duckypad_twitch/audio.py b/duckypad_twitch/audio.py new file mode 100644 index 0000000..4998e27 --- /dev/null +++ b/duckypad_twitch/audio.py @@ -0,0 +1,132 @@ +import logging +from enum import IntEnum + +import vban_cmd + +from . import configuration +from .layer import ILayer +from .states import AudioState + +logger = logging.getLogger(__name__) + + +Buttons = IntEnum("Buttons", "mute_mics only_discord only_stream", start=0) + + +class Audio(ILayer): + """Audio concrete class""" + + def __init__(self, duckypad, **kwargs): + super().__init__(duckypad) + for attr, val in kwargs.items(): + setattr(self, attr, val) + + self.reset_states() + + @property + def identifier(self): + return type(self).__name__ + + @property + def state(self): + return self._state + + @state.setter + def state(self, val): + self._state = val + + def reset_states(self): + self.state = AudioState() + for button in Buttons: + self.vm.button[button].stateonly = getattr(AudioState, button.name) + + def mute_mics(self): + self.state.mute_mics = not self.state.mute_mics + if self.state.mute_mics: + self.vm.strip[0].mute = True + self.vm.strip[1].mute = True + self.vm.strip[4].mute = True + self.logger.info("Mics Muted") + else: + self.vm.strip[0].mute = False + self.vm.strip[1].mute = False + self.vm.strip[4].mute = False + self.logger.info("Mics Unmuted") + self.vm.button[Buttons.mute_mics].stateonly = self.state.mute_mics + + def only_discord(self): + self.state.only_discord = not self.state.only_discord + if self.state.only_discord: + self.mixer.dca[0].on = False + self.vm.strip[4].mute = True + self.logger.info("Only Discord Enabled") + else: + self.vm.strip[4].mute = False + self.mixer.dca[0].on = True + self.logger.info("Only Discord Disabled") + self.vm.button[Buttons.only_discord].stateonly = self.state.only_discord + + def only_stream(self): + self.state.only_stream = not self.state.only_stream + if self.state.only_stream: + self.vm.bus[5].mute = True + self.vm.bus[6].mute = True + self.vm.strip[2].gain = -3 + self.vm.strip[3].gain = -3 + self.vm.strip[6].gain = -3 + self.logger.info("Only Stream Enabled") + else: + self.vm.strip[2].gain = 0 + self.vm.strip[3].gain = 0 + self.vm.strip[6].gain = 0 + self.vm.bus[5].mute = False + self.vm.bus[6].mute = False + self.logger.info("Only Stream Disabled") + self.vm.button[Buttons.only_stream].stateonly = self.state.only_stream + + def sound_test(self): + def toggle_soundtest(script): + onyx_conn = configuration.get("vban_onyx") + iris_conn = configuration.get("vban_iris") + assert all( + [onyx_conn, iris_conn] + ), "expected configurations for onyx_conn, iris_conn" + + with vban_cmd.api("potato", **onyx_conn) as vban: + vban.sendtext(script) + with vban_cmd.api("potato", **iris_conn) as vban: + vban.sendtext(script) + + ENABLE_SOUNDTEST = "Strip(0).A1=1; Strip(0).A2=1; Strip(0).B1=0; Strip(0).B2=0; Strip(0).mono=1;" + DISABLE_SOUNDTEST = "Strip(0).A1=0; Strip(0).A2=0; Strip(0).B1=1; Strip(0).B2=1; Strip(0).mono=0;" + + self.state.sound_test = not self.state.sound_test + if self.state.sound_test: + self.vm.strip[4].apply({"B3": False, "A1": True, "mute": False}) + self.vm.vban.outstream[0].on = True + self.vm.vban.outstream[1].on = True + self.vm.vban.outstream[0].route = 0 + self.vm.vban.outstream[1].route = 0 + toggle_soundtest(ENABLE_SOUNDTEST) + self.logger.info("Sound Test Enabled") + else: + toggle_soundtest(DISABLE_SOUNDTEST) + self.vm.vban.outstream[0].route = 5 + self.vm.vban.outstream[1].route = 6 + self.vm.strip[4].apply({"B3": True, "A1": False, "mute": True}) + self.logger.info("Sound Test Disabled") + + def solo_onyx(self): + """placeholder method.""" + + def solo_iris(self): + """placeholder method.""" + + def toggle_workstation_to_onyx(self): + self.state.ws_to_onyx = not self.state.ws_to_onyx + if self.state.ws_to_onyx: + self.vm.strip[5].gain = -6 + self.vm.vban.outstream[2].on = True + else: + self.vm.strip[5].gain = 0 + self.vm.vban.outstream[2].on = False diff --git a/duckypad_twitch/configuration.py b/duckypad_twitch/configuration.py new file mode 100644 index 0000000..85f4a8d --- /dev/null +++ b/duckypad_twitch/configuration.py @@ -0,0 +1,20 @@ +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # type: ignore + +configuration = {} + +configpath = Path.cwd() / "configs" / "duckypad.toml" +if not configpath.exists(): + raise OSError(f"unable to locate {configpath}") + +with open(configpath, "rb") as f: + configuration = tomllib.load(f) + + +def get(name): + if name in configuration: + return configuration[name] diff --git a/duckypad_twitch/duckypad.py b/duckypad_twitch/duckypad.py new file mode 100644 index 0000000..a159b5d --- /dev/null +++ b/duckypad_twitch/duckypad.py @@ -0,0 +1,55 @@ +import logging + +from .audio import Audio +from .obsws import OBSWS +from .scene import Scene +from .states import StreamState +from .streamlabs import StreamlabsController + +logger = logging.getLogger(__name__) + + +class DuckyPad: + """base DuckyPad class""" + + def __init__(self, **kwargs): + self.logger = logger.getChild(__class__.__name__) + for attr, val in kwargs.items(): + setattr(self, attr, val) + + self.stream = StreamState() + self.audio = Audio(self, vm=self.vm, mixer=self.mixer) + self.scene = Scene(self, vm=self.vm) + self.obsws = OBSWS(self) + self.streamlabs_controller = StreamlabsController(self, conn=self.sl) + + def reset(self): + ''' + apply streaming config, + then apply current scene settings + if stream is live enable both mics over vban + ''' + self.vm.apply_config("streaming") + self.audio.reset_states() + if self.stream.current_scene: + self.logger.debug( + f"Running function for current scene {self.stream.current_scene}" + ) + fn = getattr( + self.scene, + "_".join([word.lower() for word in self.stream.current_scene.split()]), + ) + fn() + if self.stream.is_live: + self.logger.debug("stream is live, enabling both mics over vban") + self.vm.vban.outstream[0].on = True + self.vm.vban.outstream[1].on = True + else: + self.logger.debug( + "stream is not live. Leaving both vban outstreams disabled" + ) + + +def connect(*args, **kwargs): + DuckyPad_cls = DuckyPad + return DuckyPad_cls(*args, **kwargs) diff --git a/duckypad_twitch/layer.py b/duckypad_twitch/layer.py new file mode 100644 index 0000000..27dd482 --- /dev/null +++ b/duckypad_twitch/layer.py @@ -0,0 +1,25 @@ +import abc +import logging + +logger = logging.getLogger(__name__) + + +class ILayer(abc.ABC): + """Abstract Base Class for Layers""" + + def __init__(self, duckypad): + self.logger = logger.getChild(self.__class__.__name__) + self._duckypad = duckypad + + @abc.abstractmethod + def identifier(): + """a unique identifier for each class""" + + @property + @abc.abstractmethod + def state(self): + """retrieve/update the states of a class""" + + @abc.abstractmethod + def reset_states(): + """reset states for a class""" diff --git a/duckypad_twitch/obsws.py b/duckypad_twitch/obsws.py new file mode 100644 index 0000000..655edb0 --- /dev/null +++ b/duckypad_twitch/obsws.py @@ -0,0 +1,97 @@ +import logging + +import obsws_python as obsws + +from . import configuration +from .layer import ILayer +from .states import OBSWSState +from .util import ensure_obsws + +logger = logging.getLogger(__name__) + + +class OBSWS(ILayer): + def __init__(self, duckypad): + super().__init__(duckypad) + self.request = self.event = None + self._state = OBSWSState() + + @property + def identifier(self): + return type(self).__name__ + + @property + def state(self): + return self._state + + @state.setter + def state(self, val): + self._state = val + + def reset_states(self): + resp = self.request.get_input_mute("Mic/Aux") + self.state.mute_mic = resp.input_muted + resp = self.request.get_stream_status() + self._duckypad.stream.is_live = resp.output_active + + def obs_connect(self): + try: + conn = configuration.get("obsws") + assert conn is not None, "expected configuration for obs" + self.request = obsws.ReqClient(**conn) + self.reset_states() + self.event = obsws.EventClient(**conn) + self.event.callback.register( + [ + self.on_stream_state_changed, + self.on_input_mute_state_changed, + self.on_current_program_scene_changed, + ] + ) + except (ConnectionRefusedError, TimeoutError) as e: + self.logger.error(f"{type(e).__name__}: {e}") + raise + + def on_current_program_scene_changed(self, data): + self._duckypad.stream.current_scene = data.scene_name + self.logger.info(f"scene switched to {self._duckypad.stream.current_scene}") + if self._duckypad.stream.current_scene in ("START", "BRB", "END"): + self.mute_mic_state(True) + + def on_input_mute_state_changed(self, data): + if data.input_name == "Mic/Aux": + self.state.mute_mic = data.input_muted + self.logger.info(f"mic was {'muted' if self.state.mute_mic else 'unmuted'}") + + def on_stream_state_changed(self, data): + self._duckypad.stream.is_live = data.output_active + self.logger.info( + f"stream is {'live' if self._duckypad.stream.is_live else 'offline'}" + ) + + @ensure_obsws + def call(self, fn_name, *args): + fn = getattr(self.request, fn_name) + resp = fn(*args) + return resp + + def start(self): + self.call("set_current_program_scene", "START") + + def brb(self): + self.call("set_current_program_scene", "BRB") + + def end(self): + self.call("set_current_program_scene", "END") + + def live(self): + self.call("set_current_program_scene", "LIVE") + + def mute_mic_state(self, val): + self.call("set_input_mute", "Mic/Aux", val) + + def toggle_mute_mic(self): + self.call("toggle_input_mute", "Mic/Aux") + + def toggle_stream(self): + self.call("toggle_stream") diff --git a/duckypad_twitch/scene.py b/duckypad_twitch/scene.py new file mode 100644 index 0000000..635c1e8 --- /dev/null +++ b/duckypad_twitch/scene.py @@ -0,0 +1,80 @@ +import logging + +from .layer import ILayer +from .states import SceneState + +logger = logging.getLogger(__name__) + + +class Scene(ILayer): + """Scene concrete class""" + + def __init__(self, duckypad, **kwargs): + super().__init__(duckypad) + for attr, val in kwargs.items(): + setattr(self, attr, val) + + self.reset_states() + + @property + def identifier(self): + return type(self).__name__ + + @property + def state(self): + return self._state + + @state.setter + def state(self, val): + self._state = val + + def reset_states(self): + self._state = SceneState() + + def onyx_only(self): + if self._duckypad.streamlabs_controller.switch_scene("onyx_only"): + self.vm.strip[2].mute = False + self.vm.strip[3].mute = True + self.logger.info("Only Onyx Scene enabled, Iris game pc muted") + + def iris_only(self): + if self._duckypad.streamlabs_controller.switch_scene("iris_only"): + self.vm.strip[2].mute = True + self.vm.strip[3].mute = False + self.logger.info("Only Iris Scene enabled, Onyx game pc muted") + + def dual_scene(self): + if self._duckypad.streamlabs_controller.switch_scene("dual_scene"): + self.vm.strip[2].apply({"mute": False, "gain": 0}) + self.vm.strip[3].apply({"A5": True, "mute": False, "gain": 0}) + self.logger.info("Dual Scene enabled") + + def onyx_big(self): + if self._duckypad.streamlabs_controller.switch_scene("onyx_big"): + self.vm.strip[2].apply({"mute": False, "gain": 0}) + self.vm.strip[3].apply({"mute": False, "gain": -3}) + self.logger.info("Onyx Big scene enabled") + + def iris_big(self): + if self._duckypad.streamlabs_controller.switch_scene("iris_big"): + self.vm.strip[2].apply({"mute": False, "gain": -3}) + self.vm.strip[3].apply({"mute": False, "gain": 0}) + self.logger.info("Iris Big enabled") + + def start(self): + if self._duckypad.streamlabs_controller.switch_scene("start"): + self.vm.strip[2].mute = True + self.vm.strip[3].mute = True + self.logger.info("Start scene enabled.. ready to go live!") + + def brb(self): + if self._duckypad.streamlabs_controller.switch_scene("brb"): + self.vm.strip[2].mute = True + self.vm.strip[3].mute = True + self.logger.info("BRB: game pcs muted") + + def end(self): + if self._duckypad.streamlabs_controller.switch_scene("end"): + self.vm.strip[2].mute = True + self.vm.strip[3].mute = True + self.logger.info("End scene enabled.") diff --git a/duckypad_twitch/states.py b/duckypad_twitch/states.py new file mode 100644 index 0000000..7aa68d3 --- /dev/null +++ b/duckypad_twitch/states.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass + + +@dataclass +class StreamState: + is_live: bool = False + current_scene: str = "" + + +@dataclass +class AudioState: + mute_mics: bool = True + only_discord: bool = False + only_stream: bool = True + sound_test: bool = True + solo_onyx: bool = True + solo_iris: bool = True + + ws_to_onyx: bool = False + + +@dataclass +class SceneState: + onyx_only: bool = False + iris_only: bool = False + dual_scene: bool = False + onyx_big: bool = False + iris_big: bool = False + start: bool = False + brb: bool = False + end: bool = False + + +@dataclass +class OBSWSState: + mute_mic: bool = True diff --git a/duckypad_twitch/streamlabs.py b/duckypad_twitch/streamlabs.py new file mode 100644 index 0000000..9763bd2 --- /dev/null +++ b/duckypad_twitch/streamlabs.py @@ -0,0 +1,127 @@ +import logging +import subprocess as sp +import time +import winreg +from asyncio.subprocess import DEVNULL +from pathlib import Path + +import slobs_websocket + +from . import configuration +from .util import ensure_sl + +logger = logging.getLogger(__name__) + + +class StreamlabsController: + SL_FULLPATH = "" + + def __init__(self, duckypad, **kwargs): + self.logger = logger.getChild(__class__.__name__) + self._duckypad = duckypad + for attr, val in kwargs.items(): + setattr(self, attr, val) + + self.proc = None + + #################################################################################### + # CONNECT/DISCONNECT from the API + #################################################################################### + + def connect(self): + try: + conn = configuration.get("streamlabs") + assert conn is not None, "expected configuration for streamlabs" + self.conn.connect(**conn) + except slobs_websocket.exceptions.ConnectionFailure as e: + self.logger.error(f"{type(e).__name__}: {e}") + raise + + self._duckypad.scene.scenes = { + scene.name: scene.id for scene in self.conn.ScenesService.getScenes() + } + self.logger.debug(f"registered scenes: {self._duckypad.scene.scenes}") + self.conn.ScenesService.sceneSwitched += self.on_scene_switched + self.conn.StreamingService.streamingStatusChange += ( + self.on_streaming_status_change + ) + + def disconnect(self): + self.conn.disconnect() + + #################################################################################### + # EVENTS + #################################################################################### + + def on_streaming_status_change(self, data): + self.logger.debug(f"streaming status changed, now: {data}") + if data in ("live", "starting"): + self._duckypad.stream.is_live = True + else: + self._duckypad.stream.is_live = False + + def on_scene_switched(self, data): + self._duckypad.stream.current_scene = data.name + self.logger.debug( + f"stream.current_scene updated to {self._duckypad.stream.current_scene}" + ) + + #################################################################################### + # START/STOP the stream + #################################################################################### + + @ensure_sl + def begin_stream(self): + if self._duckypad.stream.is_live: + self.logger.info("Stream is already online") + return + self.conn.StreamingService.toggleStreaming() + + @ensure_sl + def end_stream(self): + if not self._duckypad.stream.is_live: + self.logger.info("Stream is already offline") + return + self.conn.StreamingService.toggleStreaming() + + #################################################################################### + # CONTROL the stream + #################################################################################### + + @ensure_sl + def switch_scene(self, name): + return self.conn.ScenesService.makeSceneActive( + self._duckypad.scene.scenes[name.upper()] + ) + + #################################################################################### + # LAUNCH/SHUTDOWN the streamlabs process + #################################################################################### + + def launch(self, delay=5): + def get_slpath(): + SL_KEY = "029c4619-0385-5543-9426-46f9987161d9" + + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, r"{}".format("SOFTWARE" + "\\" + SL_KEY) + ) as regpath: + return winreg.QueryValueEx(regpath, r"InstallLocation")[0] + + try: + if not self.SL_FULLPATH: # so we only read from registry once. + self.SL_FULLPATH = Path(get_slpath()) / "Streamlabs OBS.exe" + except FileNotFoundError as e: + self.logger.exception(f"{type(e).__name__}: {e}") + raise + + if self.proc is None: + self.proc = sp.Popen(self.SL_FULLPATH, shell=False, stdout=DEVNULL) + time.sleep(delay) + self.connect() + + def shutdown(self): + self.disconnect() + time.sleep(1) + if self.proc is not None: + self.proc.terminate() + self.proc = None diff --git a/duckypad_twitch/util.py b/duckypad_twitch/util.py new file mode 100644 index 0000000..60563f0 --- /dev/null +++ b/duckypad_twitch/util.py @@ -0,0 +1,33 @@ +import slobs_websocket + + +def ensure_sl(func): + """ensure a streamlabs websocket connection has been established""" + + def wrapper(self, *args): + if self._duckypad.streamlabs_controller.conn.ws is None: + try: + try: + self.connect() + except AttributeError: + self._duckypad.streamlabs_controller.connect() + except slobs_websocket.exceptions.ConnectionFailure: + self._duckypad.streamlabs_controller.conn.ws = None + return + return func(self, *args) + + return wrapper + + +def ensure_obsws(func): + """ensure an obs websocket connection has been established""" + + def wrapper(self, *args): + if self.request is None: + try: + self.obs_connect() + except (ConnectionRefusedError, TimeoutError): + return + return func(self, *args) + + return wrapper diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4e7556a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,146 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "duckypad-twitch" +dynamic = ["version"] +description = '' +readme = "README.md" +requires-python = ">=3.7" +license = "MIT" +keywords = [] +authors = [ + { name = "onyx-and-iris", email = "75868496+onyx-and-iris@users.noreply.github.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = [ + "tomli >= 2.0.1;python_version < '3.11'", + "websocket-client", + "keyboard", + "voicemeeter-api", + "xair-api", + "slobs_websocket@git+https://git@github.com/onyx-and-iris/slobs_websocket@v0.1.4#egg=slobs_websocket", + "obsws-python", + "vban-cmd", +] + +[project.urls] +Documentation = "https://github.com/unknown/duckypad-twitch#readme" +Issues = "https://github.com/unknown/duckypad-twitch/issues" +Source = "https://github.com/unknown/duckypad-twitch" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.version] +path = "duckypad_twitch/__about__.py" + +[tool.hatch.envs.default] +dependencies = ["coverage[toml]>=6.5", "pytest"] +[tool.hatch.envs.default.scripts] +test = "pytest {args:tests}" +test-cov = "coverage run -m pytest {args:tests}" +cov-report = ["- coverage combine", "coverage report"] +cov = ["test-cov", "cov-report"] + +[[tool.hatch.envs.all.matrix]] +python = ["3.7", "3.8", "3.9", "3.10", "3.11"] + +[tool.hatch.envs.lint] +detached = true +dependencies = ["black>=23.1.0", "mypy>=1.0.0", "ruff>=0.0.243"] +[tool.hatch.envs.lint.scripts] +typing = "mypy --install-types --non-interactive {args:duckypad_twitch tests}" +style = ["ruff {args:.}", "black --check --diff {args:.}"] +fmt = ["black {args:.}", "ruff --fix {args:.}", "style"] +all = ["style", "typing"] + +[tool.black] +target-version = ["py37"] +line-length = 120 +skip-string-normalization = true + +[tool.ruff] +target-version = "py37" +line-length = 120 +select = [ + "A", + "ARG", + "B", + "C", + "DTZ", + "E", + "EM", + "F", + "FBT", + "I", + "ICN", + "ISC", + "N", + "PLC", + "PLE", + "PLR", + "PLW", + "Q", + "RUF", + "S", + "T", + "TID", + "UP", + "W", + "YTT", +] +ignore = [ + # Allow non-abstract empty methods in abstract base classes + "B027", + # Allow boolean positional values in function calls, like `dict.get(... True)` + "FBT003", + # Ignore checks for possible passwords + "S105", + "S106", + "S107", + # Ignore complexity + "C901", + "PLR0911", + "PLR0912", + "PLR0913", + "PLR0915", +] +unfixable = [ + # Don't touch unused imports + "F401", +] + +[tool.ruff.isort] +known-first-party = ["duckypad_twitch"] + +[tool.ruff.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.per-file-ignores] +# Tests can use magic values, assertions, and relative imports +"tests/**/*" = ["PLR2004", "S101", "TID252"] + +[tool.coverage.run] +source_pkgs = ["duckypad_twitch", "tests"] +branch = true +parallel = true +omit = ["duckypad_twitch/__about__.py"] + +[tool.coverage.paths] +duckypad_twitch = ["duckypad_twitch", "*/duckypad-twitch/duckypad_twitch"] +tests = ["tests", "*/duckypad-twitch/tests"] + +[tool.coverage.report] +exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fcd8230 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2023-present onyx-and-iris <75868496+onyx-and-iris@users.noreply.github.com> +# +# SPDX-License-Identifier: MIT