1
0
Fork 0

Added systemd user service and made script exit more gracefully

This commit is contained in:
Ricardo 2024-06-01 18:28:44 +02:00
parent 05146bf16e
commit 50255ba5df
3 changed files with 111 additions and 20 deletions

View file

@ -78,10 +78,17 @@ For this project I created a simple Python program to both enable the controls a
- PipeWire - PipeWire
- pactl - pactl
On Fedora 39 (assuming PipeWire is already setup) these can be installed with: On Fedora (assuming PipeWire is already setup) these can be installed with:
``` ```
sudo dnf install pulseaudio-utils python3 python3-pyusb sudo dnf install pulseaudio-utils python3 python3-pyusb
``` ```
### Install
Clone this repo and cd into it
```
git clone https://git.dymstro.nl/Dymstro/nova-chatmix-linux.git
cd nova-chatmix-linux
```
To be able to run the script as a non-root user, some udev rules need to be applied. This will allow regular users to access the base station USB device. To be able to run the script as a non-root user, some udev rules need to be applied. This will allow regular users to access the base station USB device.
@ -93,12 +100,50 @@ sudo udevadm control --reload-rules
sudo udevadm trigger sudo udevadm trigger
``` ```
Check if your audio device matches the one on line 49 of `nova.py`
```
pactl list sinks short
# The output should look something like this:
# 47 alsa_output.pci-0000_0c_00.4.iec958-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
# 77 alsa_output.pci-0000_0a_00.1.hdmi-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
# 92 alsa_output.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.iec958-stereo PipeWire s24le 2ch 48000Hz RUNNING
```
In `nova.py`:
```
## Lines 48-50
# PW_ORIGINAL_SINK = (
# "alsa_output.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.7.iec958-stereo" # Edit this line if needed
# )
```
If you want to run this script on startup you can add and enable the systemd service
```
## The systemd service expects the script in .local/bin
# Create the folder if it doesn't exist
mkdir -p ~/.local/bin
# Copy the script to the expected location
cp -i nova.py ~/.local/bin
# Create systemd user unit folder if it doesn't exist
mkdir -p ~/.config/systemd/user
# Install the service file
cp nova-chatmix.service ~/.config/systemd/user/
# Reload systemd configuration
systemctl --user daemon-reload
# Enable and start the service
systemctl --user enable nova-chatmix --now
```
### Run
You can now run the python script to use ChatMix. You can now run the python script to use ChatMix.
This will create 2 virtual sound devices: This will create 2 virtual sound devices:
- NovaGame for game/general audio - NovaGame for game/general audio
- NovaChat for chat audio - NovaChat for chat audio
``` ```
# You do not need to run this if you installed the systemd unit!
python nova.py python nova.py
``` ```
@ -128,7 +173,7 @@ nova.print_output(True)
## What's Next ## What's Next
Currently none of this is very polished, it should work, but that's about it. I would like to make this a bit more integrated, so that it just works without having to run the python script every time. Currently none of this is very polished, it should work, but that's about it. ~~I would like to make this a bit more integrated, so that it just works without having to run the python script every time.~~ I added a systemd service to automate starting the script.
There are also some other projects that try to make headset features work on linux, for example [HeadsetControl](https://github.com/Sapd/HeadsetControl). I might want to try and get some of this implemented in there. There are also some other projects that try to make headset features work on linux, for example [HeadsetControl](https://github.com/Sapd/HeadsetControl). I might want to try and get some of this implemented in there.

12
nova-chatmix.service Normal file
View file

@ -0,0 +1,12 @@
[Unit]
Description=This will enable ChatMix for the Steelseries Arctis Nova Pro Wireless
After=pipewire.service pipewire-pulse.service
Wants=network-online.target
[Service]
Restart=no
Type=simple
ExecStart=%h/.local/bin/nova.py
[Install]
WantedBy=default.target

70
nova.py Normal file → Executable file
View file

@ -1,8 +1,10 @@
#!/usr/bin/python3 #!/usr/bin/python3
# Licensed under the EUPL # Licensed under the 0BSD
from subprocess import Popen
from signal import signal, SIGINT, SIGTERM
import subprocess
from usb.core import find, USBTimeoutError from usb.core import find, USBTimeoutError
@ -50,6 +52,18 @@ class NovaProWireless:
PW_GAME_SINK = "NovaGame" PW_GAME_SINK = "NovaGame"
PW_CHAT_SINK = "NovaChat" PW_CHAT_SINK = "NovaChat"
# PipeWire virtual sink processes
PW_LOOPBACK_GAME_PROCESS = None
PW_LOOPBACK_CHAT_PROCESS = None
# Keeps track of enabled features for when close() is called
CHATMIX_CONTROLS_ENABLED = False
SONAR_ICON_ENABLED = False
CHATMIX_ENABLED = False
# Stops processes when program exits
CLOSE = False
# Selects correct device, and makes sure we can control it # Selects correct device, and makes sure we can control it
def __init__(self): def __init__(self):
self.dev = find(idVendor=self.VID, idProduct=self.PID) self.dev = find(idVendor=self.VID, idProduct=self.PID)
@ -62,18 +76,21 @@ class NovaProWireless:
def _create_msgdata(self, data: tuple[int]) -> bytes: def _create_msgdata(self, data: tuple[int]) -> bytes:
return bytes(data).ljust(self.MSGLEN, b"0") return bytes(data).ljust(self.MSGLEN, b"0")
# Enables chatmix # Enables/Disables chatmix controls
def enable_chatmix(self): def set_chatmix_controls(self, state: bool):
self.dev.write( self.dev.write(
self.ENDPOINT_TX, self.ENDPOINT_TX,
self._create_msgdata((self.TX, self.OPT_CHATMIX_ENABLE, 1)), self._create_msgdata((self.TX, self.OPT_CHATMIX_ENABLE, int(state))),
) )
self.CHATMIX_CONTROLS_ENABLED = state
# Enables Sonar Icon # Enables/Disables Sonar Icon
def enable_sonar_icon(self): def set_sonar_icon(self, state: bool):
self.dev.write( self.dev.write(
self.ENDPOINT_TX, self._create_msgdata((self.TX, self.OPT_SONAR_ICON, 1)) self.ENDPOINT_TX,
self._create_msgdata((self.TX, self.OPT_SONAR_ICON, int(state))),
) )
self.SONAR_ICON_ENABLED = state
# Sets Volume # Sets Volume
def set_volume(self, attenuation: int): def set_volume(self, attenuation: int):
@ -90,7 +107,7 @@ class NovaProWireless:
) )
# Create virtual pipewire loopback sinks, and redirect them to the real headset sink # Create virtual pipewire loopback sinks, and redirect them to the real headset sink
def _enable_virtual_sinks(self): def _start_virtual_sinks(self):
cmd = [ cmd = [
"pw-loopback", "pw-loopback",
"-P", "-P",
@ -98,15 +115,16 @@ class NovaProWireless:
"--capture-props=media.class=Audio/Sink", "--capture-props=media.class=Audio/Sink",
"-n", "-n",
] ]
subprocess.Popen(cmd + [self.PW_GAME_SINK]) self.PW_LOOPBACK_GAME_PROCESS = Popen(cmd + [self.PW_GAME_SINK])
subprocess.Popen(cmd + [self.PW_CHAT_SINK]) self.PW_LOOPBACK_CHAT_PROCESS = Popen(cmd + [self.PW_CHAT_SINK])
# ChatMix implementation # ChatMix implementation
# Continuously read from base station and ignore everything but ChatMix messages (OPT_CHATMIX) # Continuously read from base station and ignore everything but ChatMix messages (OPT_CHATMIX)
# The .read method times out and returns an error. This error is catched and basically ignored. Timeout can be configured, but not turned off (I think). # The .read method times out and returns an error. This error is catched and basically ignored. Timeout can be configured, but not turned off (I think).
def chatmix(self): def chatmix(self):
self._enable_virtual_sinks() self._start_virtual_sinks()
while True: self.CHATMIX_ENABLED = True
while not self.CLOSE:
try: try:
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN) msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
if msg[1] != self.OPT_CHATMIX: if msg[1] != self.OPT_CHATMIX:
@ -120,15 +138,15 @@ class NovaProWireless:
cmd = ["pactl", "set-sink-volume"] cmd = ["pactl", "set-sink-volume"]
# Actually change volume. Everytime you turn the dial, both volumes are set to the correct level # Actually change volume. Everytime you turn the dial, both volumes are set to the correct level
subprocess.Popen(cmd + [f"input.{self.PW_GAME_SINK}", f"{gamevol}%"]) Popen(cmd + [f"input.{self.PW_GAME_SINK}", f"{gamevol}%"])
subprocess.Popen(cmd + [f"input.{self.PW_CHAT_SINK}", f"{chatvol}%"]) Popen(cmd + [f"input.{self.PW_CHAT_SINK}", f"{chatvol}%"])
# Ignore timeout. # Ignore timeout.
except USBTimeoutError: except USBTimeoutError:
continue continue
# Prints output from base station. `debug` argument enables raw output. # Prints output from base station. `debug` argument enables raw output.
def print_output(self, debug: bool = False): def print_output(self, debug: bool = False):
while True: while not self.CLOSE:
try: try:
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN) msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
if debug: if debug:
@ -147,10 +165,26 @@ class NovaProWireless:
except USBTimeoutError: except USBTimeoutError:
continue continue
# Terminates processes and disables features
def close(self, signum, frame):
self.CLOSE = True
if self.CHATMIX_CONTROLS_ENABLED:
self.set_chatmix_controls(False)
if self.SONAR_ICON_ENABLED:
print("test")
self.set_sonar_icon(False)
if self.CHATMIX_ENABLED:
self.PW_LOOPBACK_GAME_PROCESS.terminate()
self.PW_LOOPBACK_CHAT_PROCESS.terminate()
# When run directly, just start the ChatMix implementation. (And activate the icon, just for fun) # When run directly, just start the ChatMix implementation. (And activate the icon, just for fun)
if __name__ == "__main__": if __name__ == "__main__":
nova = NovaProWireless() nova = NovaProWireless()
nova.enable_sonar_icon() nova.set_sonar_icon(True)
nova.enable_chatmix() nova.set_chatmix_controls(True)
signal(SIGINT, nova.close)
signal(SIGTERM, nova.close)
nova.chatmix() nova.chatmix()