Skip to content

Instantly share code, notes, and snippets.

Last active Jul 6, 2021
What would you like to do?
Small script to bind to VolumeDown wheel on Logitech G930 (or other) Headsets with alsa
#!/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
pp = pprint.PrettyPrinter(indent=4)
CMD_CARD_LISTING = ["aplay", "-l"]
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 is not None and is not None
def is_content_section_header(s):
Detect if an amixer content output line is the beginning of a section
return is not None \
and is not None \
and is not None
def get_card_and_headset_numbers():
Determine the card number for the headset
lines = sp.check_output(CMD_CARD_LISTING) \
.decode() \
# Get the line with the headset on it
headset_lines = [l for l in lines if 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]
card_num = int(
device_num = int(
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"]
# 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
raise Exception("Invalid direction {}".format(direction))"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)
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() \
# Find line with first section of thing that seems like volume, and find lines till next section
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")
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 is not None or 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 is not None and RE_MAX.match(l) is not None else l for l in volume_info_lines]
logging.debug("Volume info lines:")
# Attempt to parse out volume info from the lines
volume_info = { 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:")
return volume_info
def print_usage():
print("usage: headset-ctl <up|down>")
if __name__ == "__main__":
if len(sys.argv) != 2:
# 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()"Detected card number [{}], device number [{}]".format(card_num, device_num))
volume_info = get_volume_info_for_card(card_num)"Retrieved and parsed Volume info:\n{},".format(pp.pformat(volume_info)))
cmd = construct_amixer_cmd(card_num=card_num,
max_volume=volume_info["max"])"Generated command:\n{}".format(pp.pformat(cmd)))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment