initial commit

This commit is contained in:
onyx-and-iris 2023-07-05 13:44:21 +01:00
commit c1a6bbed97
18 changed files with 1314 additions and 0 deletions

170
.gitignore vendored Normal file
View File

@ -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

9
LICENSE.txt Normal file
View File

@ -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.

64
README.md Normal file
View File

@ -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/

73
__main__.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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

132
duckypad_twitch/audio.py Normal file
View File

@ -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

View File

@ -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]

View File

@ -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)

25
duckypad_twitch/layer.py Normal file
View File

@ -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"""

97
duckypad_twitch/obsws.py Normal file
View File

@ -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")

80
duckypad_twitch/scene.py Normal file
View File

@ -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.")

36
duckypad_twitch/states.py Normal file
View File

@ -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

View File

@ -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

33
duckypad_twitch/util.py Normal file
View File

@ -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

146
pyproject.toml Normal file
View File

@ -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:"]

3
tests/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2023-present onyx-and-iris <75868496+onyx-and-iris@users.noreply.github.com>
#
# SPDX-License-Identifier: MIT