~charles/gmixerctl

bd6fc60a0882810709350a05804d88a80e7faefb — Charles Daniels 5 years ago 0.0.1
initial version
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']
      )