Last active
July 6, 2021 07:20
-
-
Save t3hmrman/634be37bb27af23f5773 to your computer and use it in GitHub Desktop.
Small script to bind to VolumeDown wheel on Logitech G930 (or other) Headsets with alsa
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/bin/env python | |
# This script assumes a ton of facts about your system: | |
# 1) You're using a Logitech G930 (you can change this below) | |
# 2) aplay is installed and available on the PATH | |
# 3) amixer is installed and available on the path | |
import subprocess as sp | |
import re | |
import sys | |
import pprint | |
import logging | |
from math import ceil,floor | |
logging.basicConfig(level=logging.DEBUG) | |
pp = pprint.PrettyPrinter(indent=4) | |
DEFAULT_STEP = 0.01 | |
HEADSET_DEVICE_NAME_FRAGMENT = r"Logitech G930 Headset" | |
CMD_CARD_LISTING = ["aplay", "-l"] | |
RE_HEADSET_PATTERN = re.compile(HEADSET_DEVICE_NAME_FRAGMENT) | |
RE_CARD_NUM_PATTERN = re.compile(r"card\s+(\d)\s*:") | |
RE_DEVICE_NUM_PATTERN = re.compile(r"device\s+(\d)\s*:") | |
RE_PCM = re.compile(r"PCM") | |
RE_VOLUME = re.compile(r"Volume") | |
RE_CONTENT_SECTION_NAME = re.compile(r"name=") | |
RE_CONTENT_SECTION_IFACE = re.compile(r"iface=") | |
RE_CONTENT_SECTION_NUMID = re.compile(r"numid=") | |
RE_KEY = re.compile(r"([^=]+)=") | |
RE_VALUE = re.compile(r"=([^=]+)") | |
RE_CONTENT_SECTION_SEPERATORS = re.compile(r",|\||:") | |
RE_DB = re.compile(r"dB") | |
RE_MAX = re.compile(r"max") | |
def is_valid_direction(s): | |
""" | |
Determine if direction is valid | |
""" | |
return s == "up" or s == "down" | |
def is_likely_volume_name(s): | |
""" | |
Detect if a amixer content section line is likely to be the volume we care about | |
""" | |
return RE_PCM.search(s) is not None and RE_VOLUME.search(s) is not None | |
def is_content_section_header(s): | |
""" | |
Detect if an amixer content output line is the beginning of a section | |
""" | |
return RE_CONTENT_SECTION_NAME.search(s) is not None \ | |
and RE_CONTENT_SECTION_IFACE.search(s) is not None \ | |
and RE_CONTENT_SECTION_NUMID.search(s) is not None | |
def get_card_and_headset_numbers(): | |
""" | |
Determine the card number for the headset | |
""" | |
lines = sp.check_output(CMD_CARD_LISTING) \ | |
.decode() \ | |
.splitlines() | |
# Get the line with the headset on it | |
headset_lines = [l for l in lines if RE_HEADSET_PATTERN.search(l) is not None] | |
if len(headset_lines) != 1: | |
raise Exception("Expected only one line to have a headset!") | |
# Parse for the card number and device number | |
headset_line = headset_lines[0] | |
try: | |
card_num = int(RE_CARD_NUM_PATTERN.search(headset_line).group(1)) | |
device_num = int(RE_DEVICE_NUM_PATTERN.search(headset_line).group(1)) | |
except IndexError as ie: | |
raise Exception("Expected to find at least one group!") | |
return card_num, device_num | |
def construct_amixer_cmd(card_num=2, num_id=4, direction="up", current=-1, max_volume=-1, step=DEFAULT_STEP): | |
""" | |
Generate the amixer command that will increase/decrease the sound | |
Aiming to generate a command like amixer -c 2 cset numid=4 50% | |
Defaults are specific to the computer this was written on, they may have to be changed. | |
""" | |
# Ensure current and max are valid values | |
if current < 0 or max_volume < 0: | |
raise Exception("current and/or max volume values are invalid, please ensure parsing did not fail") | |
cmd = ["amixer"] | |
cmd.append("-c") | |
cmd.append(str(card_num)) | |
cmd.append("cset") | |
cmd.append("numid={}".format(num_id)) | |
# Calculate the increment based on step and max | |
next_increment = current | |
rounding_fn = ceil | |
# Change increment depending on direction | |
if direction == "up": | |
next_increment += max_volume * step | |
rounding_fn = ceil | |
elif direction == "down": | |
next_increment -= max_volume * step | |
rounding_fn = floor | |
else: | |
raise Exception("Invalid direction {}".format(direction)) | |
logging.info("Calculated step, {} -> {} (max is {})".format(current, next_increment, max_volume)) | |
next_increment = max(0, next_increment) | |
next_increment = min(max_volume, next_increment) | |
next_increment = rounding_fn(next_increment) | |
cmd.append(str(next_increment)) | |
return cmd | |
def get_volume_info_for_card(card_num): | |
""" | |
Retrieve and lightly parse volume info from amixer | |
""" | |
lines = sp.check_output(["amixer", "-c", str(card_num), "contents"]) \ | |
.decode() \ | |
.splitlines() | |
# Find line with first section of thing that seems like volume, and find lines till next section | |
try: | |
volume_info_start_indexes = [i for i, item in enumerate(lines) if is_content_section_header and is_likely_volume_name(item)] | |
volume_info_start_index = volume_info_start_indexes[0] | |
except IndexError as ie: | |
raise Exception("Failed to find expected volume content line beginning") | |
try: | |
volume_info_end_indexes = [i for i in range(volume_info_start_index + 1, len(lines)) if is_content_section_header(lines[i])] | |
volume_info_end_index = volume_info_end_indexes[0] | |
except IndexError as ie: | |
raise Exception("Failed to find expected volume content line end") | |
# Gather and re-distribute lines, should roughly become "key=value" lines | |
volume_info_lines = re.split(RE_CONTENT_SECTION_SEPERATORS, "".join(lines[volume_info_start_index:volume_info_end_index])) | |
volume_info_lines = [l.strip() for l in volume_info_lines if RE_KEY.search(l) is not None or RE_VALUE.search(l) is not None] | |
# Replace misleading lines, ex. prepend "db_" to lines that have max=, but contain dB (non numeric) | |
volume_info_lines = ["db_" + l if RE_DB.search(l) is not None and RE_MAX.match(l) is not None else l for l in volume_info_lines] | |
logging.debug("Volume info lines:") | |
logging.debug(pp.pformat(volume_info_lines)) | |
# Attempt to parse out volume info from the lines | |
volume_info = {RE_KEY.search(l).group(1):RE_VALUE.search(l).group(1) for l in volume_info_lines} | |
# Ensure specific values have been found | |
if volume_info["values"] is None or volume_info["max"] is None: | |
raise Exception("Most important volume info values and max are missing :(") | |
volume_info["values"] = int(volume_info["values"]) | |
volume_info["max"] = int(volume_info["max"]) | |
volume_info["numid"] = int(volume_info["numid"]) | |
logging.debug("Parsed volume info:") | |
logging.debug(pp.pformat(volume_info)) | |
return volume_info | |
def print_usage(): | |
print("usage: headset-ctl <up|down>") | |
if __name__ == "__main__": | |
if len(sys.argv) != 2: | |
print_usage() | |
sys.exit(0) | |
# Get direction from args | |
direction = sys.argv[1] | |
if not is_valid_direction(direction): | |
raise Exception("Invalid direction {}, please use 'up' or 'down'".format(direction)) | |
card_num, device_num = get_card_and_headset_numbers() | |
logging.info("Detected card number [{}], device number [{}]".format(card_num, device_num)) | |
volume_info = get_volume_info_for_card(card_num) | |
logging.info("Retrieved and parsed Volume info:\n{},".format(pp.pformat(volume_info))) | |
cmd = construct_amixer_cmd(card_num=card_num, | |
num_id=volume_info["numid"], | |
direction=direction, | |
current=volume_info["values"], | |
max_volume=volume_info["max"]) | |
logging.info("Generated command:\n{}".format(pp.pformat(cmd))) | |
sp.check_output(cmd) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment