Compare commits

...

30 Commits

Author SHA1 Message Date
4f045c00fd update fadein/fadeout logic 2026-01-31 23:59:28 +00:00
36ffdb5c61 upd readme 2026-01-20 20:15:21 +00:00
Onyx and Iris
21775e5066 toggle A4 on sound test 2026-01-13 00:03:48 +00:00
Onyx and Iris
ab9332be34 soundtest out through A5.
set mute_mics macro state on sound test off

mute game_pcs bus due to vban'ing mics over

update vban stream indexes for ws,tv broadcasts
2026-01-12 23:20:19 +00:00
Onyx and Iris
66baab1a7a set dual_stream key according to bus enum 2026-01-12 17:27:02 +00:00
Onyx and Iris
0218579ba8 upd bus enum 2026-01-12 17:26:36 +00:00
Onyx and Iris
775455e618 upd bus labels 2026-01-12 15:27:48 +00:00
Onyx and Iris
ebe9af8e56 update outputs for inputs 2026-01-12 15:24:07 +00:00
Onyx and Iris
530fa2ff34 implement gain staging configurations 2026-01-12 15:23:33 +00:00
Onyx and Iris
5992e25c79 upd VMBuses 2026-01-07 14:38:53 +00:00
b2dda092aa upd bus names 2026-01-07 14:21:43 +00:00
04047577c6 up mute_game_pcs() 2026-01-07 14:17:13 +00:00
2abcaefecc if patch_iris case 2026-01-07 13:42:09 +00:00
e9126f0f59 add more buttons for syncing 2026-01-07 13:41:29 +00:00
Onyx and Iris
26e68900aa expand on_mdirty to provide callback logic/logging. 2026-01-06 04:38:07 +00:00
Onyx and Iris
bb10786b94 keep audio states synced with mdirty 2026-01-06 02:32:42 +00:00
f94936777a add comms ducking 2026-01-06 00:12:07 +00:00
Onyx and Iris
e6d9092562 rename macro entrypoint to main.py 2026-01-04 19:59:46 +00:00
Onyx and Iris
be71c49806 should OBS be manually closed:
clean up the request socket.
the event socket should be handled by obsws-python library
2026-01-02 21:32:41 +00:00
Onyx and Iris
4f087a0358 upd docstrings 2026-01-02 20:51:50 +00:00
Onyx and Iris
e271c2a324 add DCM8 and TLM102 max gain class vars 2026-01-02 20:41:59 +00:00
Onyx and Iris
789f3e8491 bump obsws-python and vm-api dep versions 2026-01-02 20:15:39 +00:00
Onyx and Iris
bbdd64edb4 add {Audio}.mute_game_pcs()
update audio routing binds (ws, tv)
2026-01-02 20:15:23 +00:00
Onyx and Iris
62297835d9 upd tv routing incoming index 2026-01-02 18:15:47 +00:00
Onyx and Iris
4fda9ddb4d {Audio}.solo_onyx() and {Audio}.solo_iris() are now patching methods. 2026-01-02 10:12:21 +00:00
Onyx and Iris
3158ed87c7 fix iris_mic index 2026-01-02 09:37:44 +00:00
Onyx and Iris
426cd1be9f add intro banner 2026-01-02 06:31:44 +00:00
Onyx and Iris
5134c752ff upd reset bind 2026-01-02 06:15:57 +00:00
Onyx and Iris
30f06bb535 upd Bus7 label 2026-01-02 05:54:34 +00:00
Onyx and Iris
9cfba017ea make greater use of Enums to improve readability 2026-01-02 05:54:14 +00:00
12 changed files with 298 additions and 127 deletions

View File

@@ -29,23 +29,22 @@ We use a triple pc streaming setup, one gaming pc for each of us and a third pc
- Both of our microphones, as well as both gaming pc are wired into an [MR18 mixer][mr18] which itself is connected to the streaming pc. - Both of our microphones, as well as both gaming pc are wired into an [MR18 mixer][mr18] 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]. - 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].
- Voicemeeter 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. - Voicemeeter is connected to Studio ONE daw for live processing. 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 challenges of a triple pc setup. If you've ever attempted to setup a dual pc streaming setup, you may appreciate the challenges of a triple pc setup.
## Details about the code ## Details about the code
This package is for demonstration purposes only. Several of the interfaces on which it depends have been tightly coupled into a duckypad macros program. This package is for demonstration purposes only. Several of the interfaces on which it depends have been merged into a duckypad macros program.
- The package entry point can be found at `duckypad_twitch.macros.duckypad`. - The package entry point can be found at `duckypad_twitch.macros.duckypad`.
- A base DuckyPad class in duckypad.py is used to connect the various layers of the driver. - 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. - 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. - 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. - Scene switching and some audio routing are handled in the `Scene class` in scene.py.
- A `OBSWS` class is used to communicate with OBS websocket. - A `OBSWS` class is used to communicate with OBS websocket.
- Dataclasses are used to hold internal states and states are updated using event callbacks. - Dataclasses are used to hold internal states and states are updated using event callbacks.
- Decorators are used to confirm websocket connections. - 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. - Logging is included to help with debugging but also to provide stream information in real time.
## License ## License

View File

@@ -39,8 +39,8 @@ label = "Onyx Pc"
A1 = false A1 = false
A2 = false A2 = false
A3 = false A3 = false
A4 = false A4 = true
A5 = true A5 = false
B1 = false B1 = false
B2 = false B2 = false
B3 = false B3 = false
@@ -57,8 +57,8 @@ label = "Iris Pc"
A1 = false A1 = false
A2 = false A2 = false
A3 = false A3 = false
A4 = false A4 = true
A5 = true A5 = false
B1 = false B1 = false
B2 = false B2 = false
B3 = false B3 = false
@@ -90,8 +90,8 @@ gate.knob = 0
[strip-5] [strip-5]
label = "System" label = "System"
A1 = false A1 = true
A2 = true A2 = false
A3 = false A3 = false
A4 = false A4 = false
A5 = false A5 = false
@@ -107,8 +107,8 @@ limit = 0
[strip-6] [strip-6]
label = "Comms" label = "Comms"
A1 = false A1 = false
A2 = false A2 = true
A3 = true A3 = false
A4 = false A4 = false
A5 = false A5 = false
B1 = false B1 = false
@@ -125,8 +125,8 @@ k = 0
label = "Pretzel" label = "Pretzel"
A1 = false A1 = false
A2 = false A2 = false
A3 = false A3 = true
A4 = true A4 = false
A5 = false A5 = false
B1 = false B1 = false
B2 = false B2 = false
@@ -138,7 +138,7 @@ gain = 0.0
limit = 0 limit = 0
[bus-0] [bus-0]
label = "MR18" label = "System"
mono = false mono = false
eq.on = false eq.on = false
mute = false mute = false
@@ -146,7 +146,7 @@ gain = 0.0
mode = "normal" mode = "normal"
[bus-1] [bus-1]
label = "ASIO [1,2]" label = "Comms"
mono = false mono = false
eq.on = false eq.on = false
mute = false mute = false
@@ -154,7 +154,7 @@ gain = 0.0
mode = "normal" mode = "normal"
[bus-2] [bus-2]
label = "ASIO [3,4]" label = "Pretzel"
mono = false mono = false
eq.on = false eq.on = false
mute = false mute = false
@@ -162,7 +162,7 @@ gain = 0.0
mode = "normal" mode = "normal"
[bus-3] [bus-3]
label = "ASIO [5,6]" label = "GAME PCs"
mono = false mono = false
eq.on = false eq.on = false
mute = false mute = false
@@ -170,7 +170,7 @@ gain = 0.0
mode = "normal" mode = "normal"
[bus-4] [bus-4]
label = "ASIO [7,8]" label = ""
mono = false mono = false
eq.on = false eq.on = false
mute = false mute = false
@@ -194,7 +194,7 @@ gain = 0.0
mode = "normal" mode = "normal"
[bus-7] [bus-7]
label = "Both Mics" label = "Stream Mics"
mono = false mono = false
eq.on = false eq.on = false
mute = false mute = false

View File

@@ -4,7 +4,7 @@ import time
import vban_cmd import vban_cmd
from . import configuration from . import configuration
from .enums import Buttons, Strips from .enums import Buttons, VBANChannels, VMBuses, VMStrips, XAirBuses, XAirStrips
from .layer import ILayer from .layer import ILayer
from .states import AudioState from .states import AudioState
from .util import ensure_mixer_fadeout from .util import ensure_mixer_fadeout
@@ -19,6 +19,7 @@ class Audio(ILayer):
super().__init__(duckypad) super().__init__(duckypad)
for attr, val in kwargs.items(): for attr, val in kwargs.items():
setattr(self, attr, val) setattr(self, attr, val)
self.vm.observer.add(self.on_mdirty)
self.reset_states() self.reset_states()
@@ -39,47 +40,97 @@ class Audio(ILayer):
for button in Buttons: for button in Buttons:
self.vm.button[button].stateonly = getattr(AudioState, button.name) self.vm.button[button].stateonly = getattr(AudioState, button.name)
def on_mdirty(self):
"""Callback for Voicemeeter mdirty events.
This method keeps the DuckyPad state in sync with changes made from the Stream Deck"""
self.logger.debug('Voicemeeter state changed (mdirty event)')
for button in Buttons:
current_value = self.vm.button[button].stateonly
if getattr(self.state, button.name) != current_value:
match button.name:
case 'mute_mics':
if current_value:
self.logger.info('Mics Muted')
else:
self.logger.info('Mics Unmuted')
case 'only_discord':
if current_value:
self.mixer.strip[XAirStrips.comms].send[XAirBuses.stream_mix].level = -90
self.logger.info('Only Discord Enabled')
else:
self.mixer.strip[XAirStrips.comms].send[XAirBuses.stream_mix].level = -24
self.logger.info('Only Discord Disabled')
case 'only_stream':
if current_value:
self.logger.info('Only Stream Enabled')
else:
self.logger.info('Only Stream Disabled')
case 'sound_test':
if current_value:
self.logger.info('Sound Test Enabled')
else:
self.logger.info('Sound Test Disabled')
case 'patch_onyx':
if current_value:
self.logger.info('Onyx mic has been patched')
else:
self.logger.info('Onyx mic has been unpatched')
case 'patch_iris':
if current_value:
self.logger.info('Iris mic has been patched')
else:
self.logger.info('Iris mic has been unpatched')
case 'mute_game_pcs':
if current_value:
self.logger.info('Game PCs Muted')
else:
self.logger.info('Game PCs Unmuted')
setattr(self.state, button.name, current_value)
def mute_mics(self): def mute_mics(self):
self.state.mute_mics = not self.state.mute_mics self.state.mute_mics = not self.state.mute_mics
if self.state.mute_mics: if self.state.mute_mics:
self.vm.strip[0].mute = True self.vm.strip[VMStrips.onyx_mic].mute = True
self.vm.strip[1].mute = True self.vm.strip[VMStrips.iris_mic].mute = True
self.vm.strip[4].mute = True
self.logger.info('Mics Muted') self.logger.info('Mics Muted')
else: else:
self.vm.strip[0].mute = False self.vm.strip[VMStrips.onyx_mic].mute = False
self.vm.strip[1].mute = False self.vm.strip[VMStrips.iris_mic].mute = False
self.vm.strip[4].mute = False
self.logger.info('Mics Unmuted') self.logger.info('Mics Unmuted')
self.vm.button[Buttons.mute_mics].stateonly = self.state.mute_mics self.vm.button[Buttons.mute_mics].stateonly = self.state.mute_mics
def only_discord(self): def only_discord(self):
self.state.only_discord = not self.state.only_discord self.state.only_discord = not self.state.only_discord
if self.state.only_discord: if self.state.only_discord:
self.mixer.dca[0].on = False self.vm.bus[VMBuses.both_mics].mute = True
self.vm.strip[4].mute = True self.mixer.strip[XAirStrips.comms].send[XAirBuses.stream_mix].level = -90
self.logger.info('Only Discord Enabled') self.logger.info('Only Discord Enabled')
else: else:
self.vm.strip[4].mute = False self.mixer.strip[XAirStrips.comms].send[XAirBuses.stream_mix].level = -24
self.mixer.dca[0].on = True self.vm.bus[VMBuses.both_mics].mute = False
self.logger.info('Only Discord Disabled') self.logger.info('Only Discord Disabled')
self.vm.button[Buttons.only_discord].stateonly = self.state.only_discord self.vm.button[Buttons.only_discord].stateonly = self.state.only_discord
def only_stream(self): def only_stream(self):
self.state.only_stream = not self.state.only_stream self.state.only_stream = not self.state.only_stream
if self.state.only_stream: if self.state.only_stream:
self.vm.bus[5].mute = True self.vm.bus[VMBuses.onyx_mic].mute = True
self.vm.bus[6].mute = True self.vm.bus[VMBuses.iris_mic].mute = True
self.vm.strip[2].gain = -3 self.vm.strip[VMStrips.onyx_pc].gain = -3
self.vm.strip[3].gain = -3 self.vm.strip[VMStrips.iris_pc].gain = -3
self.vm.strip[6].gain = -3 self.vm.strip[VMStrips.comms].gain = -6
self.vm.strip[VMStrips.pretzel].gain = -3
self.logger.info('Only Stream Enabled') self.logger.info('Only Stream Enabled')
else: else:
self.vm.strip[2].gain = 0 self.vm.strip[VMStrips.onyx_pc].gain = 0
self.vm.strip[3].gain = 0 self.vm.strip[VMStrips.iris_pc].gain = 0
self.vm.strip[6].gain = 0 self.vm.strip[VMStrips.comms].gain = 0
self.vm.bus[5].mute = False self.vm.strip[VMStrips.pretzel].gain = 0
self.vm.bus[6].mute = False self.vm.bus[VMBuses.onyx_mic].mute = False
self.vm.bus[VMBuses.iris_mic].mute = False
self.logger.info('Only Stream Disabled') self.logger.info('Only Stream Disabled')
self.vm.button[Buttons.only_stream].stateonly = self.state.only_stream self.vm.button[Buttons.only_stream].stateonly = self.state.only_stream
@@ -99,6 +150,7 @@ class Audio(ILayer):
ENABLE_SOUNDTEST = { ENABLE_SOUNDTEST = {
'A1': True, 'A1': True,
'A2': True, 'A2': True,
'A4': False,
'B1': False, 'B1': False,
'B2': False, 'B2': False,
'mono': True, 'mono': True,
@@ -106,6 +158,7 @@ class Audio(ILayer):
DISABLE_SOUNDTEST = { DISABLE_SOUNDTEST = {
'A1': False, 'A1': False,
'A2': False, 'A2': False,
'A4': True,
'B1': True, 'B1': True,
'B2': True, 'B2': True,
'mono': False, 'mono': False,
@@ -113,70 +166,127 @@ class Audio(ILayer):
self.state.sound_test = not self.state.sound_test self.state.sound_test = not self.state.sound_test
if self.state.sound_test: if self.state.sound_test:
self.vm.strip[Strips.onyx_mic].apply({'A1': True, 'B1': False, 'B3': False, 'mute': False}) self.vm.strip[VMStrips.onyx_mic].apply({'A5': True, 'B1': False, 'B3': False, 'mute': False})
self.vm.strip[Strips.iris_mic].apply({'A1': True, 'B2': False, 'B3': False, 'mute': False}) self.vm.strip[VMStrips.iris_mic].apply({'A5': True, 'B2': False, 'B3': False, 'mute': False})
self.vm.vban.outstream[0].on = True self.vm.bus[VMBuses.game_pcs].mute = True
self.vm.vban.outstream[1].on = True self.vm.vban.outstream[VBANChannels.onyx_mic].apply({'on': True, 'route': 4})
self.vm.vban.outstream[0].route = 0 self.vm.vban.outstream[VBANChannels.iris_mic].apply({'on': True, 'route': 4})
self.vm.vban.outstream[1].route = 0
toggle_soundtest(ENABLE_SOUNDTEST) toggle_soundtest(ENABLE_SOUNDTEST)
self.logger.info('Sound Test Enabled') self.logger.info('Sound Test Enabled')
else: else:
toggle_soundtest(DISABLE_SOUNDTEST) toggle_soundtest(DISABLE_SOUNDTEST)
self.vm.vban.outstream[0].route = 5 self.vm.vban.outstream[VBANChannels.onyx_mic].route = 5
self.vm.vban.outstream[1].route = 6 self.vm.vban.outstream[VBANChannels.iris_mic].route = 6
self.vm.strip[Strips.onyx_mic].apply({'A1': False, 'B1': True, 'B3': True, 'mute': True}) self.vm.bus[VMBuses.game_pcs].mute = False
self.vm.strip[Strips.iris_mic].apply({'A1': False, 'B2': True, 'B3': True, 'mute': True}) self.vm.strip[VMStrips.onyx_mic].apply({'A5': False, 'B1': True, 'B3': True, 'mute': True})
self.vm.strip[VMStrips.iris_mic].apply({'A5': False, 'B2': True, 'B3': True, 'mute': True})
self.vm.button[Buttons.mute_mics].stateonly = True
self.logger.info('Sound Test Disabled') self.logger.info('Sound Test Disabled')
self.vm.button[Buttons.sound_test].stateonly = self.state.sound_test
@ensure_mixer_fadeout @ensure_mixer_fadeout
def stage_onyx_mic(self): def stage_onyx_mic(self):
"""Gain stage SE Electronics DCM8 with phantom power""" """Gain stage onyx mic"""
self.mixer.headamp[10].phantom = True config = configuration.mic('onyx')
for i in range(21):
self.mixer.headamp[10].gain = i self.mixer.headamp[XAirStrips.onyx_mic].phantom = config['phantom']
for i in range(config['gain'] + 1):
self.mixer.headamp[XAirStrips.onyx_mic].gain = i
time.sleep(0.1) time.sleep(0.1)
self.logger.info('Onyx Mic Staged with Phantom Power') self.logger.info('Onyx Mic Staged with Phantom Power')
@ensure_mixer_fadeout @ensure_mixer_fadeout
def stage_iris_mic(self): def stage_iris_mic(self):
"""Gain stage TLM102 with phantom power""" """Gain stage iris mic"""
self.mixer.headamp[11].phantom = True config = configuration.mic('iris')
for i in range(31):
self.mixer.headamp[11].gain = i self.mixer.headamp[XAirStrips.iris_mic].phantom = config['phantom']
for i in range(config['gain'] + 1):
self.mixer.headamp[XAirStrips.iris_mic].gain = i
time.sleep(0.1) time.sleep(0.1)
self.logger.info('Iris Mic Staged with Phantom Power') self.logger.info('Iris Mic Staged with Phantom Power')
def unstage_onyx_mic(self): def unstage_onyx_mic(self):
"""Unstage SE Electronics DCM8 and disable phantom power""" """Unstage onyx mic, if phantom power was enabled, disable it"""
for i in reversed(range(21)): config = configuration.mic('onyx')
self.mixer.headamp[10].gain = i
for i in reversed(range(config['gain'] + 1)):
self.mixer.headamp[XAirStrips.onyx_mic].gain = i
time.sleep(0.1) time.sleep(0.1)
self.mixer.headamp[10].phantom = False if config['phantom']:
self.logger.info('Onyx Mic Unstaged and Phantom Power Disabled') self.mixer.headamp[XAirStrips.onyx_mic].phantom = False
self.logger.info('Onyx Mic Unstaged and Phantom Power Disabled')
else:
self.logger.info('Onyx Mic Unstaged')
def unstage_iris_mic(self): def unstage_iris_mic(self):
"""Unstage TLM102 and disable phantom power""" """Unstage iris mic, if phantom power was enabled, disable it"""
for i in reversed(range(31)): config = configuration.mic('iris')
self.mixer.headamp[11].gain = i
for i in reversed(range(config['gain'] + 1)):
self.mixer.headamp[XAirStrips.iris_mic].gain = i
time.sleep(0.1) time.sleep(0.1)
self.mixer.headamp[11].phantom = False if config['phantom']:
self.logger.info('Iris Mic Unstaged and Phantom Power Disabled') self.mixer.headamp[XAirStrips.iris_mic].phantom = False
self.logger.info('Iris Mic Unstaged and Phantom Power Disabled')
else:
self.logger.info('Iris Mic Unstaged')
def solo_onyx(self): def patch_onyx(self):
"""placeholder method""" self.state.patch_onyx = not self.state.patch_onyx
if self.state.patch_onyx:
self.vm.patch.asio[0].set(11)
self.logger.info('Onyx mic has been patched')
else:
self.vm.patch.asio[0].set(0)
self.logger.info('Onyx mic has been unpatched')
self.vm.button[Buttons.patch_onyx].stateonly = self.state.patch_onyx
def solo_iris(self): def patch_iris(self):
"""placeholder method""" self.state.patch_iris = not self.state.patch_iris
if self.state.patch_iris:
self.vm.patch.asio[2].set(12)
self.logger.info('Iris mic has been patched')
else:
self.vm.patch.asio[2].set(0)
self.logger.info('Iris mic has been unpatched')
self.vm.button[Buttons.patch_iris].stateonly = self.state.patch_iris
def _fade_mixer(self, target_fader, fade_in=True): def mute_game_pcs(self):
"""Fade the mixer's fader to the target level.""" self.state.mute_game_pcs = not self.state.mute_game_pcs
current_fader = self.mixer.lr.mix.fader if self.state.mute_game_pcs:
step = 1 if fade_in else -1 self.vm.bus[VMBuses.game_pcs].mute = True
while (fade_in and current_fader < target_fader) or (not fade_in and current_fader > target_fader): self.logger.info('Game PCs Muted')
current_fader += step else:
self.mixer.lr.mix.fader = current_fader self.vm.bus[VMBuses.game_pcs].mute = False
time.sleep(0.05) self.logger.info('Game PCs Unmuted')
self.vm.button[Buttons.mute_game_pcs].stateonly = self.state.mute_game_pcs
### Workstation and TV Audio Routing via VBAN ###
def __fadein_main(self, target_level: float, duration: float = 5.0):
current_level = self.mixer.lr.mix.fader
level_difference = abs(target_level - current_level)
steps = max(10, min(100, int(level_difference)))
step_duration = duration / steps
level_step = (target_level - current_level) / steps
for _ in range(steps):
current_level += level_step
self.mixer.lr.mix.fader = current_level
time.sleep(step_duration)
def __fadeout_main(self, target_level: float, duration: float = 5.0):
current_level = self.mixer.lr.mix.fader
level_difference = abs(current_level - target_level)
steps = max(10, min(100, int(level_difference)))
step_duration = duration / steps
level_step = (current_level - target_level) / steps
for _ in range(steps):
current_level -= level_step
self.mixer.lr.mix.fader = current_level
time.sleep(step_duration)
def _toggle_workstation_routing(self, state_attr, target_name, vban_config_key): def _toggle_workstation_routing(self, state_attr, target_name, vban_config_key):
"""Toggle routing of workstation audio to either Onyx or Iris via VBAN.""" """Toggle routing of workstation audio to either Onyx or Iris via VBAN."""
@@ -189,17 +299,17 @@ class Audio(ILayer):
if new_state: if new_state:
with vban_cmd.api('potato', outbound=True, **target_conn) as vban: with vban_cmd.api('potato', outbound=True, **target_conn) as vban:
vban.vban.instream[2].on = True vban.vban.instream[6].on = True
self.vm.strip[5].gain = -6 self.vm.strip[5].gain = -6
self.vm.vban.outstream[3].on = True self.vm.vban.outstream[2].on = True
self._fade_mixer(-90, fade_in=False) self.__fadeout_main(-90)
self.logger.info(f'Workstation audio routed to {target_name}') self.logger.info(f'Workstation audio routed to {target_name}')
else: else:
with vban_cmd.api('potato', outbound=True, **target_conn) as vban: with vban_cmd.api('potato', outbound=True, **target_conn) as vban:
vban.vban.instream[2].on = False vban.vban.instream[6].on = False
self.vm.strip[5].gain = 0 self.vm.strip[5].gain = 0
self.vm.vban.outstream[3].on = False self.vm.vban.outstream[2].on = False
self._fade_mixer(-36, fade_in=True) self.__fadein_main(-24)
self.logger.info('Workstation audio routed back to monitor speakers') self.logger.info('Workstation audio routed back to monitor speakers')
def toggle_workstation_to_onyx(self): def toggle_workstation_to_onyx(self):
@@ -225,7 +335,7 @@ class Audio(ILayer):
vban_tv.strip[3].A1 = False vban_tv.strip[3].A1 = False
vban_tv.strip[3].gain = -6 vban_tv.strip[3].gain = -6
vban_tv.vban.outstream[0].on = True vban_tv.vban.outstream[0].on = True
vban_target.vban.instream[2].on = True vban_target.vban.instream[7].on = True
self.logger.info(f'TV audio routed to {target_name}') self.logger.info(f'TV audio routed to {target_name}')
else: else:
with ( with (
@@ -235,7 +345,7 @@ class Audio(ILayer):
vban_tv.strip[3].A1 = True vban_tv.strip[3].A1 = True
vban_tv.strip[3].gain = 0 vban_tv.strip[3].gain = 0
vban_tv.vban.outstream[0].on = False vban_tv.vban.outstream[0].on = False
vban_target.vban.instream[2].on = False vban_target.vban.instream[7].on = False
self.logger.info(f'TV audio routing to {target_name} disabled') self.logger.info(f'TV audio routing to {target_name} disabled')
def toggle_tv_audio_to_onyx(self): def toggle_tv_audio_to_onyx(self):

View File

@@ -18,3 +18,14 @@ with open(configpath, 'rb') as f:
def get(name): def get(name):
if name in configuration: if name in configuration:
return configuration[name] return configuration[name]
def mic(name):
assert 'microphones' in configuration, 'No microphones defined in configuration'
try:
mic_key = configuration['microphones'][name]
mic_cfg = configuration['microphone'][mic_key]
return mic_cfg
except KeyError as e:
raise KeyError(f'Microphone configuration for "{name}" not found') from e

View File

@@ -1,6 +1,28 @@
from enum import IntEnum from enum import IntEnum
Buttons = IntEnum('Buttons', 'mute_mics only_discord only_stream', start=0) Buttons = IntEnum(
'Buttons', 'mute_mics only_discord only_stream sound_test patch_onyx patch_iris mute_game_pcs', start=0
)
Strips = IntEnum('Strips', 'onyx_mic iris_mic onyx_pc iris_pc', start=0) # Voicemeeter Channels
Buses = IntEnum('Buses', 'MR18 ASIO[1,2] ASIO[3,4] ASIO[5,6] ASIO[7,8] onyx_mic iris_mic both_mics', start=5) VMStrips = IntEnum('Strips', 'onyx_mic iris_mic onyx_pc iris_pc st_input_5 system comms pretzel', start=0)
VMBuses = IntEnum('Buses', 'system comms pretzel game_pcs output_5 onyx_mic iris_mic both_mics', start=0)
# VBAN Channels
VBANChannels = IntEnum('VBANChannels', 'onyx_mic iris_mic comms workstation', start=0)
# XAir Channels
class XAirStrips(IntEnum):
system = 0
comms = 2
pretzel = 4
game_pcs = 6
onyx_mic = 10
iris_mic = 11
class XAirBuses(IntEnum):
onyx_mix = 0
iris_mix = 2
stream_mix = 4

View File

@@ -1 +1 @@
from .duckypad import run from .main import run

View File

@@ -9,6 +9,8 @@ from duckypad_twitch import configuration
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def register_hotkeys(duckypad): def register_hotkeys(duckypad):
def audio_hotkeys(): def audio_hotkeys():
@@ -20,12 +22,13 @@ def register_hotkeys(duckypad):
keyboard.add_hotkey('F18', duckypad.audio.stage_iris_mic) keyboard.add_hotkey('F18', duckypad.audio.stage_iris_mic)
keyboard.add_hotkey('shift+F17', duckypad.audio.unstage_onyx_mic) keyboard.add_hotkey('shift+F17', duckypad.audio.unstage_onyx_mic)
keyboard.add_hotkey('shift+F18', duckypad.audio.unstage_iris_mic) keyboard.add_hotkey('shift+F18', duckypad.audio.unstage_iris_mic)
keyboard.add_hotkey('F19', duckypad.audio.solo_onyx) keyboard.add_hotkey('F19', duckypad.audio.patch_onyx)
keyboard.add_hotkey('F20', duckypad.audio.solo_iris) keyboard.add_hotkey('F20', duckypad.audio.patch_iris)
keyboard.add_hotkey('F21', duckypad.audio.toggle_workstation_to_onyx) keyboard.add_hotkey('F21', duckypad.audio.mute_game_pcs)
keyboard.add_hotkey('F22', duckypad.audio.toggle_workstation_to_iris) keyboard.add_hotkey('alt+F13', duckypad.audio.toggle_workstation_to_onyx)
keyboard.add_hotkey('F23', duckypad.audio.toggle_tv_audio_to_onyx) keyboard.add_hotkey('alt+F14', duckypad.audio.toggle_workstation_to_iris)
keyboard.add_hotkey('F24', duckypad.audio.toggle_tv_audio_to_iris) keyboard.add_hotkey('alt+F15', duckypad.audio.toggle_tv_audio_to_onyx)
keyboard.add_hotkey('alt+F16', duckypad.audio.toggle_tv_audio_to_iris)
def scene_hotkeys(): def scene_hotkeys():
keyboard.add_hotkey('ctrl+F13', duckypad.scene.start) keyboard.add_hotkey('ctrl+F13', duckypad.scene.start)
@@ -36,11 +39,11 @@ def register_hotkeys(duckypad):
keyboard.add_hotkey('ctrl+F18', duckypad.scene.iris_solo) keyboard.add_hotkey('ctrl+F18', duckypad.scene.iris_solo)
def obsws_hotkeys(): def obsws_hotkeys():
keyboard.add_hotkey('ctrl+alt+F13', duckypad.obsws.start_stream) keyboard.add_hotkey('ctrl+F22', duckypad.obsws.start_stream)
keyboard.add_hotkey('ctrl+alt+F14', duckypad.obsws.stop_stream) keyboard.add_hotkey('ctrl+F23', duckypad.obsws.stop_stream)
def duckypad_hotkeys(): def duckypad_hotkeys():
keyboard.add_hotkey('ctrl+F21', duckypad.reset) keyboard.add_hotkey('ctrl+F24', duckypad.reset)
for step in ( for step in (
audio_hotkeys, audio_hotkeys,
@@ -55,7 +58,7 @@ def run():
xair_config = configuration.get('xair') xair_config = configuration.get('xair')
with ( with (
voicemeeterlib.api('potato') as vm, voicemeeterlib.api('potato', mdirty=True) as vm,
xair_api.connect('MR18', **xair_config) as mixer, xair_api.connect('MR18', **xair_config) as mixer,
duckypad_twitch.connect(vm=vm, mixer=mixer) as duckypad, duckypad_twitch.connect(vm=vm, mixer=mixer) as duckypad,
): ):
@@ -63,5 +66,21 @@ def run():
register_hotkeys(duckypad) register_hotkeys(duckypad)
banner_width = 80
logger.info(
'\n'.join(
(
'\n' + '#' * banner_width,
'Duckypad Twitch is running. ',
'Run sound test and gain stage mics to verify audio setup.',
'Then start the stream.',
"Don't forget Voicemeeter starts in Only Stream mode!",
'So first unmute mics, then give stream introduction, then disable Only Stream mode.',
'Now you are live with mics unmuted!',
'#' * banner_width,
)
)
)
print('press ctrl+shift+F24 to quit') print('press ctrl+shift+F24 to quit')
keyboard.wait('ctrl+shift+F24') keyboard.wait('ctrl+shift+F24')

View File

@@ -54,12 +54,12 @@ class OBSWS(ILayer):
for client in (self.request, self.event): for client in (self.request, self.event):
if client: if client:
client.disconnect() client.disconnect()
self.request = self.event = None
### Event Handlers ### ### Event Handlers ###
def on_stream_state_changed(self, data): def on_stream_state_changed(self, data):
self._duckypad.stream.is_live = data.output_active self._duckypad.stream.is_live = data.output_active
self.logger.info(f'stream is {"live" if self._duckypad.stream.is_live else "offline"}')
def on_current_program_scene_changed(self, data): def on_current_program_scene_changed(self, data):
self._duckypad.stream.current_scene = data.scene_name self._duckypad.stream.current_scene = data.scene_name
@@ -77,8 +77,10 @@ class OBSWS(ILayer):
case 'IRIS SOLO': case 'IRIS SOLO':
self.logger.info('Iris Solo Scene enabled, Onyx game pc muted') self.logger.info('Iris Solo Scene enabled, Onyx game pc muted')
def on_exit_started(self, _): def on_exit_started(self, data):
self.event.unsubscribe() self.logger.info('OBS is exiting, disconnecting...')
self.request.disconnect()
self.request = self.event = None
### OBSWS Request Wrappers ### ### OBSWS Request Wrappers ###
@@ -98,6 +100,7 @@ class OBSWS(ILayer):
return return
self._call('start_stream') self._call('start_stream')
self.logger.info('stream started')
def stop_stream(self): def stop_stream(self):
resp = self._call('get_stream_status') resp = self._call('get_stream_status')
@@ -106,3 +109,4 @@ class OBSWS(ILayer):
return return
self._call('stop_stream') self._call('stop_stream')
self.logger.info('stream stopped')

View File

@@ -1,6 +1,6 @@
import logging import logging
from .enums import Strips from .enums import VMBuses, VMStrips
from .layer import ILayer from .layer import ILayer
from .states import SceneState from .states import SceneState
@@ -33,36 +33,35 @@ class Scene(ILayer):
self._state = SceneState() self._state = SceneState()
def start(self): def start(self):
self.vm.strip[Strips.onyx_pc].mute = True self.vm.strip[VMStrips.onyx_pc].mute = True
self.vm.strip[Strips.iris_pc].mute = True self.vm.strip[VMStrips.iris_pc].mute = True
self.obsws.switch_to_scene('START') self.obsws.switch_to_scene('START')
def dual_stream(self): def dual_stream(self):
ENABLE_PC = { ENABLE_PC = {
'mute': False, 'mute': False,
'A5': True, f'A{VMBuses.game_pcs + 1}': True, # Voicemeeter A output is 1-indexed
'gain': 0,
} }
self.vm.strip[Strips.onyx_pc].apply(ENABLE_PC) self.vm.strip[VMStrips.onyx_pc].apply(ENABLE_PC)
self.vm.strip[Strips.iris_pc].apply(ENABLE_PC) self.vm.strip[VMStrips.iris_pc].apply(ENABLE_PC)
self.obsws.switch_to_scene('DUAL STREAM') self.obsws.switch_to_scene('DUAL STREAM')
def brb(self): def brb(self):
self.vm.strip[Strips.onyx_pc].mute = True self.vm.strip[VMStrips.onyx_pc].mute = True
self.vm.strip[Strips.iris_pc].mute = True self.vm.strip[VMStrips.iris_pc].mute = True
self.obsws.switch_to_scene('BRB') self.obsws.switch_to_scene('BRB')
def end(self): def end(self):
self.vm.strip[Strips.onyx_pc].mute = True self.vm.strip[VMStrips.onyx_pc].mute = True
self.vm.strip[Strips.iris_pc].mute = True self.vm.strip[VMStrips.iris_pc].mute = True
self.obsws.switch_to_scene('END') self.obsws.switch_to_scene('END')
def onyx_solo(self): def onyx_solo(self):
self.vm.strip[Strips.onyx_pc].mute = False self.vm.strip[VMStrips.onyx_pc].mute = False
self.vm.strip[Strips.iris_pc].mute = True self.vm.strip[VMStrips.iris_pc].mute = True
self.obsws.switch_to_scene('ONYX SOLO') self.obsws.switch_to_scene('ONYX SOLO')
def iris_solo(self): def iris_solo(self):
self.vm.strip[Strips.onyx_pc].mute = True self.vm.strip[VMStrips.onyx_pc].mute = True
self.vm.strip[Strips.iris_pc].mute = False self.vm.strip[VMStrips.iris_pc].mute = False
self.obsws.switch_to_scene('IRIS SOLO') self.obsws.switch_to_scene('IRIS SOLO')

View File

@@ -13,8 +13,9 @@ class AudioState:
only_discord: bool = False only_discord: bool = False
only_stream: bool = True only_stream: bool = True
sound_test: bool = False sound_test: bool = False
solo_onyx: bool = False patch_onyx: bool = True
solo_iris: bool = False patch_iris: bool = True
mute_game_pcs: bool = False
ws_to_onyx: bool = False ws_to_onyx: bool = False
ws_to_iris: bool = False ws_to_iris: bool = False

View File

@@ -1,5 +1,7 @@
def ensure_obsws(func): def ensure_obsws(func):
"""ensure an obs websocket connection has been established""" """ensure an obs websocket connection has been established
Used as a decorator for functions that require an obs websocket connection"""
def wrapper(self, *args): def wrapper(self, *args):
if self.request is None: if self.request is None:
@@ -11,8 +13,11 @@ def ensure_obsws(func):
return wrapper return wrapper
def ensure_mixer_fadeout(func): def ensure_mixer_fadeout(func):
"""ensure mixer fadeout is stopped before proceeding""" """ensure mixer is faded out before proceeding (disable monitor speaker)
Used as a decorator for functions that require the mixer to be faded out"""
def wrapper(self, *args): def wrapper(self, *args):
if self.mixer.lr.mix.fader > -90: if self.mixer.lr.mix.fader > -90:
@@ -21,6 +26,7 @@ def ensure_mixer_fadeout(func):
return wrapper return wrapper
def to_snakecase(scene_name: str) -> str: def to_snakecase(scene_name: str) -> str:
"""Convert caplitalized words to lowercase snake_case""" """Convert caplitalized words to lowercase snake_case"""
return '_'.join(word.lower() for word in scene_name.split()) return '_'.join(word.lower() for word in scene_name.split())

View File

@@ -23,9 +23,9 @@ classifiers = [
dynamic = ["version"] dynamic = ["version"]
dependencies = [ dependencies = [
"keyboard", "keyboard",
"obsws-python>=1.7", "obsws-python>=1.8.0",
"vban-cmd>=2.5.2", "vban-cmd>=2.5.2",
"voicemeeter-api>=2.6.1", "voicemeeter-api>=2.7.1",
"xair-api>=2.4.1", "xair-api>=2.4.1",
] ]