A => .gitignore +7 -0
@@ 1,7 @@
+*.egg-info
+__pycache__
+*.pyc
+*.swp
+*.swo
+build
+dist
A => LICENSE +28 -0
@@ 1,28 @@
+Copyright (c) 2018, Charles Daniels (except where otherwise noted)
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
A => README.md +54 -0
@@ 1,54 @@
+# gmixerctl
+
+gmixerctl is a GUI wrapper for the OpenBSD
+[`mxerctl`](https://man.openbsd.org/mixerctl) command. gmixerctl aims to have
+100% feature parity with mixerctl, and may add a few more convenience features
+in the future (such as setting sndiod flags).
+
+gmixerctl is written in in Python3 with tkinter, and operates by wrapping
+the mixerctl command itself.
+
+## Screenshots
+
+![](screenshots/basic.png)
+![](screenshots/outputs.png)
+
+## Installation
+
+```
+python3 setup.py install
+```
+
+## Contributing
+
+Contributions are more than welcome. If there is a feature you would like added
+to gmixerctl, please feel free to open a pull request. In particular, I would
+appreciate help implementing the following features:
+
+* Changing the mixer device at run time (this may be a Tk limitation, but I'm
+ not experienced enough with Tk at the moment)
+
+* Configuring `sndiod` flags (i.e. a menu for running `rcctl set sndiod flags
+ -f rsnd/X ; rcctl restart sndiod`)
+
+* An elegant solution for rendering enum type controls with only the choices
+ on and off as checkboxes, rather than dropdowns.
+
+## Rationale
+
+My first attempt at writing gmixerctl was in the form of an imgui based C++
+application which worked by calling into the same API as `mixerctl.c`, which is
+to say just sending `ioctl()` calls straight to `/dev/mixer`. This attempt was
+abandoned due to a number of issues, namely synchronous/immediate mode
+rendering proved difficult to combine with polling the mixer state - setting
+the framerate too high could crash the application, presumably by exceeding the
+maximum throughput of the mixer device.
+
+Python was selected as the implementation language, as I was already familiar
+with it. Tkinter was chosen as the UI library due to it's reputation for
+ease-of use, which I found it to live up to.
+
+In principle, I could have written a Python wrapper for the relevant audio
+control related `ioctl()` calls, but chose not to as the output of `mixerctl`
+is easy to parse, and has proved not to be a bottleneck. If someone else would
+like to write such a wrapper, I would be happy to use it however.
A => gmixerctl/__init__.py +0 -0
A => gmixerctl/__main__.py +4 -0
@@ 1,4 @@
+from . import gui
+from . import util
+
+gui.main()
A => gmixerctl/constants.py +53 -0
@@ 1,53 @@
+import logging
+
+# update interval to re-schedule update_state()
+update_interval = 250
+
+label_width = 20
+control_width = 30
+
+log_level = logging.INFO
+
+# control names to appear in the basic tab
+basic_controls = [
+ "outputs.master",
+ "outputs.master.slaves",
+ "outputs.master.mute",
+ "record.volume",
+ "record.mute",
+ "record.enable",
+ "record.slaves"
+]
+
+version = "0.0.1"
+
+license = """
+Copyright (c) 2018, Charles Daniels (except where otherwise noted)
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+"""
A => gmixerctl/gui.py +245 -0
@@ 1,245 @@
+import logging
+import tkinter
+import tkinter.ttk as ttk
+import time
+
+from . import mixerctl
+from . import util
+from . import constants
+
+def update_state(root, tkvars):
+ """update_state
+
+ Update the state of the GUI to reflect the current mixer status.
+ """
+
+ controls = mixerctl.get_state()
+ for name in controls:
+ control = controls[name]
+ if control["type"] == "value":
+ if type(control["current"]) == tuple:
+ tkvars[name].set(control["current"][0])
+ else:
+ tkvars[name].set(control["current"])
+
+ elif control["type"] == "enum":
+ tkvars[name].set(control["current"])
+
+ elif control["type"] == "set":
+ for p in control["possible"]:
+ if p in control["current"]:
+ tkvars[name].choices[p].set(1)
+ else:
+ tkvars[name].choices[p].set(0)
+
+ # schedule ourself to run again
+ root.after(constants.update_interval, update_state, root, tkvars)
+
+class update_value:
+
+ def __init__(this, name, first=True):
+ this.name = name
+ this.first = first;
+
+ def __call__(this, val):
+ if this.first:
+ # don't set values when we first initialize the sliders
+ logging.debug("drop request {}={}".format(this.name, val))
+ this.first = False
+ else:
+ mixerctl.set_value(this.name, val)
+
+class MultiSelect(tkinter.Frame):
+ # https://stackoverflow.com/a/34550169
+ #
+ def __init__(this, parent, text, choices, choiceptr = None):
+ tkinter.Frame.__init__(this, parent)
+
+ menubutton = tkinter.Menubutton(this,
+ text=text,
+ indicatoron=True,
+ borderwidth=1,
+ relief="raised")
+
+ menu = tkinter.Menu(menubutton, tearoff=False)
+ menubutton.configure(menu=menu)
+ menubutton.configure(width = constants.control_width)
+ menubutton.pack(padx=10, pady=10)
+
+ this.name = text
+
+ this.choices = {}
+ if choiceptr is not None:
+ this.choices = choiceptr
+
+ for choice in choices:
+ choiceVar = None
+ if choiceptr is None:
+ this.choices[choice] = tkinter.IntVar(value=0)
+ choiceVar = this.choices[choice]
+ else:
+ choiceVar = choiceptr[choice]
+
+ menu.add_checkbutton(label=choice,
+ variable=choiceVar,
+ onvalue=1,
+ offvalue=0,
+ command=this.update
+ )
+
+ def update(this):
+ mixerctl.set_value(this.name, ",".join(
+ [x for x in this.choices if this.choices[x].get() == 1]))
+
+def render_control(parent, control, tabs, tkvars):
+ name = control["name"]
+
+
+ # slider label - we do this as a separate "widget" to place the
+ # label on the left of the control and thus free up vertical space
+ text_widget = tkinter.Label(
+ parent,
+ text = name,
+ width = constants.label_width)
+ text_widget.pack(side=tkinter.LEFT)
+
+ # create a new callback object - we need to use the update_value
+ # class so that mixerctl.set_value() knows what control we want
+ # changed
+ callback = update_value(name)
+
+ # handle "value" types
+ if control["type"] == "value":
+
+ # backing integer variable for the slider
+ if name not in tkvars:
+ tkvars[name] = tkinter.IntVar();
+
+ scale = tkinter.Scale(
+ parent,
+ variable = tkvars[name],
+ label = "",
+ to = 255,
+ orient = tkinter.HORIZONTAL,
+ length = 200,
+ command = callback,
+ )
+ # scale.config(width = constants.control_width)
+ scale.pack(side=tkinter.RIGHT)
+
+ elif control["type"] == "enum":
+
+ # don't drop the first input for dropdowns
+ callback.first = False
+
+ if name not in tkvars:
+ tkvars[name] = tkinter.StringVar()
+
+ # single-item selection dropdown
+ menu = tkinter.OptionMenu(
+ parent,
+ tkvars[name],
+ *control["possible"],
+ command = callback,
+ )
+ menu.config(width = constants.control_width)
+ menu.pack(side=tkinter.RIGHT)
+
+ elif control["type"] == "set":
+
+ menu = None
+ if name in tkvars:
+ menu = MultiSelect(
+ parent,
+ name,
+ control["possible"],
+ tkvars[name].choices
+ )
+ menu.pack(side=tkinter.RIGHT)
+ else:
+ menu = MultiSelect(
+ parent,
+ name,
+ control["possible"],
+ )
+ menu.pack(side=tkinter.RIGHT)
+ tkvars[name] = menu
+
+
+ else:
+ logging.warning("unhandled control type for control {}".format(control))
+
+
+def main():
+
+ util.setup_logging(level=constants.log_level)
+
+ logging.debug("gmixerctl GUI started")
+
+ root = tkinter.Tk()
+ nb = ttk.Notebook(root)
+
+ # get initial state
+ controls = mixerctl.get_state()
+ util.log_pretty(logging.debug, controls)
+
+ tabs = {}
+ tkvars = {}
+
+ # custom-build "basic" tab
+ for name in controls:
+
+ # only display the controls we have configured
+ if name not in constants.basic_controls:
+ continue
+
+ control = controls[name]
+
+ # make sure the tab for this type of control exists
+ tab_name = "basic"
+ if tab_name not in tabs:
+ tabs[tab_name] = ttk.Frame(nb)
+ nb.add(tabs[tab_name], text=tab_name)
+
+ # create the frame for this control
+ frame = ttk.Frame(tabs[tab_name])
+ render_control(frame, control, tabs, tkvars)
+ frame.pack()
+
+
+ # automatically generate the rest of the tabs
+ for name in controls:
+ control = controls[name]
+
+ # make sure the tab for this type of control exists
+ tab_name = name.split(".")[0]
+ if tab_name not in tabs:
+ tabs[tab_name] = ttk.Frame(nb)
+ nb.add(tabs[tab_name], text=tab_name)
+
+ # create the frame for this control
+ frame = ttk.Frame(tabs[tab_name])
+ render_control(frame, control, tabs, tkvars)
+ frame.pack()
+
+ # add about tab
+ about = ttk.Frame(nb)
+ version = tkinter.Label(
+ about,
+ text = "gmixerctl version {}".format(constants.version),
+ )
+ version.pack()
+
+ license = tkinter.Label(
+ about,
+ text = constants.license
+ )
+ license.pack()
+ about.pack()
+ nb.add(about, text = "about")
+
+ nb.pack()
+
+ root.after(10, update_state, root, tkvars)
+ root.mainloop()
+
A => gmixerctl/mixerctl.py +84 -0
@@ 1,84 @@
+import os
+import logging
+import subprocess
+
+from . import util
+
+def parse_line(line):
+ """parse_line
+
+ Parse a single line from the output of mixerctl -v
+
+ :param line:
+ """
+
+ name = line.split("=")[0]
+ rest = line.split("=")[1]
+
+ state = {}
+ state["name"] = name
+
+ if "[" in rest:
+ state["type"] = "enum"
+ state["current"] = rest.split("[")[0].strip()
+
+ state["possible"] = []
+ for val in rest.split("[")[1].split():
+ if val == "]":
+ continue
+ else:
+ state["possible"].append(val)
+
+ elif "{" in rest:
+ state["type"] = "set"
+ rest = rest.replace("}", "")
+ state["current"] = tuple(rest.split("{")[0].strip().split(","))
+
+ state["possible"] = []
+ for val in rest.split("{")[1].split():
+ if val == "]":
+ continue
+ else:
+ state["possible"].append(val)
+
+ else:
+ state["type"] = "value"
+ if "," in rest:
+ state["current"] = tuple((int(x) for x in rest.split(",")))
+ else:
+ state["current"] = int(rest)
+
+
+ return name, state
+
+def get_state():
+ """get_state
+
+ Get the current mixer state.
+ """
+
+ raw = subprocess.check_output(["mixerctl", "-v"], stderr=subprocess.STDOUT)
+ raw = raw.decode()
+
+ control = {}
+
+ for line in [x.strip() for x in raw.split("\n")]:
+ if line == "":
+ # ignore blank lines
+ continue
+
+ key, val = parse_line(line)
+ control[key] = val
+
+ return control
+
+def set_value(control, value):
+ """set_value
+
+ :param control:
+ :param value:
+ """
+ logging.debug("setting {} = {}".format(control, value))
+ raw = subprocess.check_output(["mixerctl", "{}={}".format(control, value)],
+ stderr=subprocess.STDOUT)
+ logging.debug("mixerctl says {}".format(raw))
A => gmixerctl/util.py +15 -0
@@ 1,15 @@
+import logging
+import traceback
+import pprint
+
+def setup_logging(level=logging.INFO):
+ logging.basicConfig(level=level,
+ format='%(levelname)s: %(message)s',
+ datefmt='%H:%M:%S')
+
+def log_exception(e):
+ logging.error("Exception: {}".format(e))
+ logging.debug("".join(traceback.format_tb(e.__traceback__)))
+
+def log_pretty(logfunc, obj):
+ logfunc(pprint.pformat(obj))
A => screenshots/basic.png +0 -0
A => screenshots/outputs.png +0 -0
A => setup.py +32 -0
@@ 1,32 @@
+#!/usr/bin/env python
+
+from gmixerctl import constants as constants
+from setuptools import setup
+from setuptools import find_packages
+
+short_description = \
+ 'GUI Wrapper for OpenBSD\'s mixerctl'
+
+long_description = '''
+'''.lstrip() # remove leading newline
+
+classifiers = [
+ # see http://pypi.python.org/pypi?:action=list_classifiers
+ ]
+
+setup(name="gmixerctl",
+ version=constants.version,
+ description=short_description,
+ long_description=long_description,
+ author="Charles Daniels",
+ author_email="cdaniels@fastmail.com",
+ url="https://github.com/charlesdaniels/gmixerctl",
+ license='BSD',
+ classifiers=classifiers,
+ keywords='',
+ packages=find_packages(),
+ entry_points={'console_scripts':
+ ['gmixerctl=gmixerctl.gui:main']},
+ package_dir={'gmixerctl': 'gmixerctl'},
+ platforms=['POSIX']
+ )