pax_global_header00006660000000000000000000000064137154415230014517gustar00rootroot0000000000000052 comment=6dafb19e75bf77408dcf6c8ce5913d33defa4a34 soundcraft-utils-0.4.0/000077500000000000000000000000001371544152300150265ustar00rootroot00000000000000soundcraft-utils-0.4.0/.flake8000066400000000000000000000000431371544152300161760ustar00rootroot00000000000000[flake8] ignore = E501, E402, F541 soundcraft-utils-0.4.0/.github/000077500000000000000000000000001371544152300163665ustar00rootroot00000000000000soundcraft-utils-0.4.0/.github/workflows/000077500000000000000000000000001371544152300204235ustar00rootroot00000000000000soundcraft-utils-0.4.0/.github/workflows/publish_pypi_release.yml000066400000000000000000000011341371544152300253540ustar00rootroot00000000000000name: Upload Python Package on: release: types: [created] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v1 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install setuptools wheel twine - name: Build and publish env: TWINE_USERNAME: '__token__' TWINE_PASSWORD: ${{ secrets.PYPI_RELEASEKEY }} run: | python setup.py sdist bdist_wheel twine upload dist/* soundcraft-utils-0.4.0/.github/workflows/python-package.yml000066400000000000000000000023041371544152300240570ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Pytest on: push: branches: [ release ] pull_request: branches: [ release ] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.6, 3.7, 3.8] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install flake8 pytest python setup.py develop - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | pytest soundcraft-utils-0.4.0/.gitignore000066400000000000000000000001071371544152300170140ustar00rootroot00000000000000build/ dist/ *.egg-info/ __pycache__ pypi.*key Pipfile.lock .tox .eggs soundcraft-utils-0.4.0/CONTRIBUTORS.md000066400000000000000000000006621371544152300173110ustar00rootroot00000000000000Contributors ------------ - [Jim Ramsay](mailto:i.am@jimramsay.com) - Author - [Christoph](mailto:soffioalcuore@posteo.net) - Testing and suggestions - [Pete Merges](mailto:pdmerges@gmail.com) - Notepad-8FX support and testing - [Viktor Mastoridis](mailto:viktor.mastoridis@gmail.com) - Notepad-5 support and testing Artwork ------- - Icon by [Flat Icons](https://www.flaticon.com/authors/flat-icons) from http://www.flaticon.com/ soundcraft-utils-0.4.0/LICENSE000066400000000000000000000021001371544152300160240ustar00rootroot00000000000000MIT License Copyright (c) 2020 Jim Ramsay Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. soundcraft-utils-0.4.0/Pipfile000066400000000000000000000003661371544152300163460ustar00rootroot00000000000000[[source]] url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" [packages] pyusb = "*" pydbus = "*" [dev-packages] black = "*" flake8 = "*" isort = "*" pytest = "*" tox = "*" tox-pyenv = "*" [pipenv] allow_prereleases = true soundcraft-utils-0.4.0/README.md000066400000000000000000000062241371544152300163110ustar00rootroot00000000000000Linux Utilities for Soundcraft Mixers ===================================== [Soundcraft Notepad](https://www.soundcraft.com/en/product_families/notepad-series) mixers are pretty nice small-sized mixer boards with Harmon USB I/O built-in. While the USB audio works great in alsa without any additional configuration needed, there are some advanced features available to the Windows driver that have no Linux equivalent. Most importantly, the USB routing for the capture channels is software-controlled, and requires an additional utility. For example, by default the Notepad-12FX sends the Master L&R outputs to USB capture channels 3 and 4, but this routing can be changed to input 3&4, input 5&6, or input 7&8. This tool aims to give this same software control of the USB capture channel routing to Linux users. Supported models: - Notepad-12FX - Notepad-8FX - Notepad-5 Prerequisites ------------- The dbus service relies on [PyGObject](https://pygobject.readthedocs.io/en/latest/index.html) which is not available via pypi without a lot of dev libraries for it to compile against. It is usually easier to install separately using your distribution's package installation tools. Under Ubuntu, the following should work: ```bash sudo apt install python3-gi ``` Installation ------------ ### Installation ```bash sudo pip install soundcraft-utils ``` It is not recommended to use `--user` mode and install this system-wide so that the dbus service auto-start can reliably find the right python libs. Set up the DBUS service so it can access the system bus and be auto-started on demand: ```bash sudo soundcraft_dbus_service --setup ``` The dbus service will run as root, providing access to the underlying USB device so the `soundcraft_ctl` user-facing part can be run by an unprivileged account. ### Upgrading Simply update your package from pip, and rerun the 'setup' to ensure the dbus service is upgraded to the latest version: ```bash sudo pip install -U soundcraft-utils sudo soundcraft_dbus_service --setup ``` ### Uninstallation You can remove the dbus and xdg files first by running the following as root: ```bash sudo soundcraft_dbus_service --uninstall ``` Then remove the package with pip: ```bash sudo pip uninstall soundcraft-utils ``` ### Prepared Packages #### Arch Linux, Manjaro soundcraft-utils are available in [AUR](https://aur.archlinux.org/packages/soundcraft-utils/): ```bash yay -S soundcraft-utils ``` Usage ----- ### GUI ```bash soundcraft_gui ``` - Select the desired input using the up and down arrow keys or using the mouse - Apply the selection by clicking "Apply" (ALT+A) - Instead of applying the selection, clicking "Reset" (ALT+R) will set the selection back to the current state of the mixer (if known) ### CLI List possible channel routing choices: ```bash soundcraft_ctl [--no-dbus] -l ``` Set channel routing: ```bash soundcraft_ctl [--no-dbus] -s ``` When using the `--no-dbus`, write access to the underling USB device is required. Normally only root can do this, unless you've added some custom udev rules. TODO ---- - Polkit restrictions on the dbus service - Multiple device support - Auto-duck feature - Firmware upgrade soundcraft-utils-0.4.0/setup.cfg000066400000000000000000000000421371544152300166430ustar00rootroot00000000000000[metadata] license_file = LICENSE soundcraft-utils-0.4.0/setup.py000066400000000000000000000023031371544152300165360ustar00rootroot00000000000000import re from setuptools import find_packages, setup version = re.search( '^__version__\\s*=\\s*"(.*)"', open("soundcraft/__init__.py").read(), re.M ).group(1) with open("README.md", "rb") as fh: long_description = fh.read().decode("utf-8") setup( name="soundcraft-utils", version=version, description="Soundcraft Notepad control utilities", long_description=long_description, long_description_content_type="text/markdown", author="Jim Ramsay", author_email="i.am@jimramsay.com", url="https://github.com/lack/soundcraft-utils", license="MIT", packages=find_packages(), classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Multimedia :: Sound/Audio :: Mixers", ], python_requires=">=3.6", install_requires=["pyusb", "pydbus"], entry_points={ "console_scripts": [ "soundcraft_ctl=soundcraft.cli:main", "soundcraft_dbus_service=soundcraft.dbus:main", ], "gui_scripts": ["soundcraft_gui=soundcraft.gui:main"], }, package_data={"soundcraft": ["data/*/*/*", "data/*/*"]}, ) soundcraft-utils-0.4.0/soundcraft/000077500000000000000000000000001371544152300171765ustar00rootroot00000000000000soundcraft-utils-0.4.0/soundcraft/__init__.py000066400000000000000000000021571371544152300213140ustar00rootroot00000000000000# # Copyright (c) 2020 Jim Ramsay # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. __version__ = "0.4.0" soundcraft-utils-0.4.0/soundcraft/cli.py000066400000000000000000000102041371544152300203140ustar00rootroot00000000000000# # Copyright (c) 2020 Jim Ramsay # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import argparse import sys def autodetect(dbus=True): if dbus: try: from .dbus import Client, DbusInitializationError client = Client() result = client.autodetect() if result is None: print(f"No devices found... waiting for one to appear") result = client.waitForDevice() return result except DbusInitializationError as e: print(e) sys.exit(2) else: from .notepad import autodetect as npdetect return npdetect() def max_lengths(dev): target_len = max([len(x) for x in dev.routingTarget]) source_len = 0 for source in dev.sources.values(): source_len = max(source_len, *[len(x) for x in source]) for (target, source) in dev.fixedRouting: target_len = max(target_len, *[len(x) for x in target]) source_len = max(source_len, *[len(x) for x in source]) return (target_len, source_len) def show(dev): (target_len, source_len) = max_lengths(dev) table_width = target_len + 4 + source_len + 4 print("-" * table_width) for (target, source) in dev.fixedRouting: for i in range(0, len(target)): print(f"{target[i]:<{target_len}} <- {source[i]}") print("-" * table_width) target = [x.ljust(target_len) for x in dev.routingTarget] notarget = (" " * target_len, " " * target_len) for (i, source) in enumerate(dev.sources.items()): sep = " " input = [x.ljust(source_len) for x in source[1]] if dev.routingSource is None or dev.routingSource == "UNKNOWN": sep = "??" selected = target if i == 0 else notarget elif dev.routingSource == source[0]: selected = target sep = "<-" else: selected = notarget for j in range(0, len(selected)): idx = f"[{i}]" if j == 0 else "" print(f"{selected[j]} {sep} {input[j]} {idx}") print("-" * table_width) def main(): parser = argparse.ArgumentParser() parser.add_argument( "--no-dbus", help="Use direct USB device access instead of DBUS service access", action="store_true", ) parser.add_argument( "-l", "--list", help="List the available source routing options", action="store_true", ) parser.add_argument( "-s", "--set", help="Set the specified source to route to the USB capture input" ) args = parser.parse_args() if args.list or args.set: dev = autodetect(dbus=not args.no_dbus) if dev is None: print(f"No compatible device detected") sys.exit(1) print(f"Detected a {dev.name}") if args.set: try: dev.routingSource = args.set except ValueError: print(f"Unrecognised input choice {args.set}") print(f"Run -l to list the valid choices") sys.exit(1) show(dev) else: parser.print_help() if __name__ == "__main__": main() soundcraft-utils-0.4.0/soundcraft/data/000077500000000000000000000000001371544152300201075ustar00rootroot00000000000000soundcraft-utils-0.4.0/soundcraft/data/dbus-1/000077500000000000000000000000001371544152300212025ustar00rootroot00000000000000soundcraft-utils-0.4.0/soundcraft/data/dbus-1/system-services/000077500000000000000000000000001371544152300243475ustar00rootroot00000000000000soundcraft-utils-0.4.0/soundcraft/data/dbus-1/system-services/soundcraft.utils.notepad.service000066400000000000000000000001001371544152300326600ustar00rootroot00000000000000[D-BUS Service] Name=$busname Exec=$dbus_service_bin User=root soundcraft-utils-0.4.0/soundcraft/data/dbus-1/system.d/000077500000000000000000000000001371544152300227505ustar00rootroot00000000000000soundcraft-utils-0.4.0/soundcraft/data/dbus-1/system.d/soundcraft-utils.conf000066400000000000000000000005651371544152300271330ustar00rootroot00000000000000 soundcraft-utils-0.4.0/soundcraft/data/xdg/000077500000000000000000000000001371544152300206715ustar00rootroot00000000000000soundcraft-utils-0.4.0/soundcraft/data/xdg/soundcraft-utils.desktop000066400000000000000000000003701371544152300255720ustar00rootroot00000000000000[Desktop Entry] Type=Application Version=1.0 Name=soundcraft-utils Comment=A tool for setting up inner routing of Soundcraft Notepad devices Exec=soundcraft_gui Icon=soundcraft-utils Terminal=false Categories=Multimedia;AudioVideo;Player;Recorder; soundcraft-utils-0.4.0/soundcraft/data/xdg/soundcraft-utils.png000066400000000000000000000171671371544152300247210ustar00rootroot00000000000000PNG  IHDR\rfsBIT|d pHYsbb8ztEXtSoftwarewww.inkscape.org<IDATxy}{}̹@ @G"$+vIq˖l9f\mRR%'-K.rU,R*&&Ap,vXsv{c=tϱ7=3~=#Vrn Џ6X"#0 \u5C'~؏kUID]NCO=9~9-}TQW!>'TrOṯe',ZO>{췗;xj)RRCh5S5Pu$]ǞC_owG_C z0gD'P(׬O&-#-o;xm Pc%-7 ; Cv DԗjQ@yDv1%\Wq$R4L7^րHx=&P0-%bфOB*:}5ːƫu]Rрuï@BLƅu ^BON|o-^ו(̾j t|{Uu=j?(o.^ )+Փq]9۱uCm~͖ W^[Z=v40d%JI R 7t:܉G:mbqHJJhN`/M߱R@Bwbt$w\ \'݉وg^w^HH]x#7i"MՊlMSV#i|[W3gyKj+ )U 1^ƛt%ד `۰qC4)n;( Ch 2^h @kTlQ~ 6nnRz7okI7"Iժ`b<޷ӷ>St^MF%;Ǟ[um {IJ2Pou[`e֌3ouO`(ow ٸ }e0ouh2NwJݵhY%Z:L ֍; hFG y-ƻrk9^0(?es H|l?H45[LTʶ-Cۭ3^K~],US27>kۿ=Cx9qP762^K~\ *np\?Բx%;^4njgۖ!s׆{l ʵbYm fɎ:] ت~QropcSVnlm=!깮u[ x/t$4gZa {FsS(lʇXP,_x/uEXrV;w]l Z:005Q059-n3YR 3(ԭBm4=ʭx ]Z'NFy[0ޕ[R{i+ݩVzx<˔b%!ޤ8ض˰c/M~@?6r V / &] @4`|LO;taz_g{; D񆓔x.eRQ7<;F S^SJ e;d%-$$d_,9OPvxP7} Œyi7`m-&Yd -K5^)+}g o~x%9ޤjzoˏZo(haCFöNs/4Aw-jPS;ojX!pmmw נŻuw p֖|]x5hA|g ( ( ( (#J,XN\չ9_v\U2!e)HH &Q;%3Yj'Gl RH]VJvrHT\wLHߣV]u6uvlH֐ĦҐr4+Ù}ٵONx,}˺:˧t `яߓé-iL1XJe) ~Mlˠ1{ϴ'G=v$`zK \W*=ahfg5ɹ}lWjB#s`3y7 ɸHݯ֬?G߁sEkrـijF iyuPf_v*!kTӆ{!ƿ`R{'}݁R@>g.H.HH7d'3;^;rhXc)&27IV jxhdDyp:w<]:PDd``Y 'H8uT} 8vm0-!5̈tZC}8<~=וP `p}ux >1n8`-8UK3Tiz>.F*w] /]d b 76Fk.h%Ɗ-ϡ-UT#RkvCGeK-^Ժy +FԵH;B4`ݛRR !񦌋Ni#H&ꊸkD -6\Yx2~v[ Aͼp%mFJڰOMGZ4nft0aZ76YwWVfmZ=0Ѭ0¦4 zjwi*_:uvS_ E h7jIT2ȚuHCö NcPYڵ4 }#1(u֤3.^Nk7o>]+$ % }e!ItV,]֬V*s <<;o;~-GߎUm:F6^lYGke+^ތzwAO_=rl[CmnX֚TڃպRaJ_:ߜV}f&Oljm)3>5hd\iRROLS9/bPXUɓx{..gtvױS T`UHeZFk J1PV#Ri%O'z %FJKJ%H/U &\7Mg)[AEl@ZˊQ+Q.U_p Ǒ0 ]+4҅B kZHJ%ͯ f(Ha&ꪵR#$E*#z٬3 C Z!(̦~zW?BZ M81 hC_莫@no0FpNLǶ2L<}ɫYG!>}!n `Hc+@1U`@jQ8Eysxjjɥ& ؖ6L90Uvu21[ Ů\ƽ%.e680jAL7lLƍ @&ۚu:Bj_.BN2=ro-zJ ŕKj)2d\ [.K&޺k3xfU7)>q u5)" Ov,; CB|!(fZ{t.w`s?#Bvwmhq~u‹k!A{f9} Ox]NIENDB`soundcraft-utils-0.4.0/soundcraft/data/xdg/soundcraft-utils.svg000066400000000000000000000206171371544152300247260ustar00rootroot00000000000000soundcraft-utils-0.4.0/soundcraft/dbus.py000066400000000000000000000355741371544152300205230ustar00rootroot00000000000000# # Copyright (c) 2020 Jim Ramsay # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import argparse import shutil import subprocess import sys from pathlib import Path from string import Template try: import gi except ModuleNotFoundError: print( "\nThe PyGI library must be installed from your distribution; usually called python-gi, python-gobject, or pygobject\n" ) raise gi.require_version("GUdev", "1.0") from gi.repository import GLib, GUdev from pydbus import SystemBus from pydbus.generic import signal import soundcraft import soundcraft.notepad BUSNAME = "soundcraft.utils.notepad" class NotepadDbus(object): """ """ InterfaceName = "soundcraft.utils.notepad.device" def __init__(self, dev): self._dev = dev @property def name(self): return self._dev.name @property def fixedRouting(self): return self._dev.fixedRouting @property def routingTarget(self): return self._dev.routingTarget @property def sources(self): return self._dev.sources @property def routingSource(self): return self._dev.routingSource @routingSource.setter def routingSource(self, request): self._dev.routingSource = request self.PropertiesChanged( self.InterfaceName, {"routingSource": self.routingSource}, [] ) PropertiesChanged = signal() class Service: """ """ InterfaceName = "soundcraft.utils.notepad" PropertiesChanged = signal() Added = signal() Removed = signal() def __init__(self): self.object = None self.bus = SystemBus() self.udev = GUdev.Client(subsystems=["usb/usb_device"]) self.udev.connect("uevent", self.uevent) self.loop = GLib.MainLoop() self.busname = self.bus.publish(BUSNAME, self) def run(self): self.tryRegister() if not self.hasDevice(): print(f"Waiting for one to arrive...") self.loop.run() @property def version(self): return soundcraft.__version__ @property def devices(self): if self.hasDevice(): return [self.object._path] return [] def Shutdown(self): print("Shutting down") self.unregister() self.loop.quit() def objPath(self, idx): return f"/soundcraft/utils/notepad/{idx}" def tryRegister(self): if self.hasDevice(): print( f"There is already a {self.object._wrapped._dev.name} on the bus at {self.object._path}" ) return dev = soundcraft.notepad.autodetect() if dev is None: print(f"No recognised device was found") return # Reset any stored state dev.resetState() path = self.objPath(0) wrapped = NotepadDbus(dev) self.object = self.bus.register_object(path, wrapped, None) self.object._wrapped = wrapped self.object._path = path print( f"Presenting {self.object._wrapped._dev.name} on the system bus as {path}" ) self.Added(path) self.PropertiesChanged(self.InterfaceName, {"devices": self.devices}, []) def hasDevice(self): return self.object is not None def unregister(self): if not self.hasDevice(): return path = self.object._path print( f"Removed {self.object._wrapped._dev.name} AKA {path} from the system bus" ) self.object.unregister() self.object = None self.PropertiesChanged(self.InterfaceName, {"devices": self.devices}, []) self.Removed(path) def uevent(self, observer, action, device): if action == "add": idVendor = int(device.get_property("ID_VENDOR_ID"), 16) idProduct = int(device.get_property("ID_PRODUCT_ID"), 16) if idVendor == soundcraft.notepad.HARMAN_USB: print( f"Checking new Soundcraft device ({idVendor:0>4x}:{idProduct:0>4x})..." ) self.tryRegister() if not self.hasDevice(): print( f"Contact the developer for help adding support for your advice" ) elif action == "remove" and self.hasDevice(): # UDEV adds leading 0s to decimal numbers. They're not octal. Why?? busnum = int(device.get_property("BUSNUM").lstrip("0")) devnum = int(device.get_property("DEVNUM").lstrip("0")) objectdev = self.object._wrapped._dev.dev if busnum == objectdev.bus and devnum == objectdev.address: self.unregister() def findDataFiles(subdir): result = {} modulepaths = soundcraft.__path__ for path in modulepaths: path = Path(path) datapath = path / "data" / subdir result[datapath] = [] for f in datapath.glob("**/*"): if f.is_dir(): continue result[datapath].append(f.relative_to(datapath)) return result def serviceExePath(): exename = Path(sys.argv[0]).resolve() if exename.suffix == ".py": raise ValueError( "Running setup out of a module-based execution is not supported" ) return exename SCALABLE_ICONDIR = Path("/usr/local/share/icons/hicolor/scalable/apps/") def setup_dbus(cfgroot=Path("/usr/share/dbus-1")): templateData = { "dbus_service_bin": str(serviceExePath()), "busname": BUSNAME, } sources = findDataFiles("dbus-1") for (srcpath, files) in sources.items(): for f in files: src = srcpath / f dst = cfgroot / f print(f"Installing {src} -> {dst}") with open(src, "r") as srcfile: srcTemplate = Template(srcfile.read()) with open(dst, "w") as dstfile: dstfile.write(srcTemplate.substitute(templateData)) print(f"Starting service version {soundcraft.__version__}...") client = Client() print(f"Version running: {client.serviceVersion()}") print(f"Setup is complete") print(f"Run soundcraft_gui or soundcraft_ctl as a regular user") def setup_xdg(): sources = findDataFiles("xdg") for (srcpath, files) in sources.items(): for f in files: src = srcpath / f if src.suffix == ".desktop": subprocess.run(["xdg-desktop-menu", "install", "--novendor", str(src)]) elif src.suffix == ".png": for size in (16, 24, 32, 48, 256): subprocess.run( [ "xdg-icon-resource", "install", "--novendor", "--size", str(size), str(src), ] ) elif src.suffix == ".svg": SCALABLE_ICONDIR.mkdir(parents=True, exist_ok=True) shutil.copy(src, SCALABLE_ICONDIR) print(f"Installed all xdg application launcher files") def setup(): setup_dbus() setup_xdg() def uninstall_dbus(cfgroot=Path("/usr/share/dbus-1")): try: client = Client() print(f"Shutting down service version {client.serviceVersion()}") client.shutdown() print(f"Stopped") except Exception: print("Service not running") sources = findDataFiles("dbus-1") for (srcpath, files) in sources.items(): for f in files: path = cfgroot / f print(f"Removing {path}") try: path.unlink() except Exception as e: print(e) print(f"Dbus service is unregistered") def uninstall_xdg(): sources = findDataFiles("xdg") for (srcpath, files) in sources.items(): for f in files: print(f"Uninstalling {f.name}") if f.suffix == ".desktop": subprocess.run(["xdg-desktop-menu", "uninstall", "--novendor", f.name]) elif f.suffix == ".png": for size in (16, 24, 32, 48, 256): subprocess.run( ["xdg-icon-resource", "uninstall", "--size", str(size), f.name] ) elif f.suffix == ".svg": svg = SCALABLE_ICONDIR / f.name if svg.exists(): svg.unlink() print(f"Removed all xdg application launcher files") def uninstall(): uninstall_dbus() uninstall_xdg() class DbusInitializationError(RuntimeError): pass class VersionIncompatibilityError(DbusInitializationError): def __init__(self, serviceVersion, pid, clientVersion): super().__init__( f"Running service version {serviceVersion} (PID {pid}) is incompatible with the client version {clientVersion} - Kill and restart the dbus service" ) class DbusServiceSetupError(DbusInitializationError): def __init__(self): super().__init__( f"No dbus service found for {BUSNAME} - Run 'soundcraft_dbus_service --setup' as root to enable it" ) class Client: MGRPATH = "/soundcraft/utils/notepad" def __init__(self, added_cb=None, removed_cb=None): self.bus = SystemBus() self.dbusmgr = self.bus.get(".DBus") self.dbusmgr.onNameOwnerChanged = self._nameChanged self.manager = None self.initManager() self.ensureServiceVersion(allowRestart=True) if removed_cb is not None: self.deviceRemoved.connect(removed_cb) if added_cb is not None: self.deviceAdded.connect(added_cb) self.autodetect() def initManager(self): try: self.manager = self.bus.get(BUSNAME, self.MGRPATH) self.manager.onAdded = self._onAdded self.manager.onRemoved = self._onRemoved except Exception as e: if "org.freedesktop.DBus.Error.ServiceUnknown" in e.message: raise DbusServiceSetupError() raise e def servicePid(self): return self.dbusmgr.GetConnectionUnixProcessID(BUSNAME) def serviceVersion(self): return self.manager.version def _canShutdown(self): return callable(getattr(self.manager, "Shutdown", None)) def ensureServiceVersion(self, allowRestart=False): mgrVersion = self.serviceVersion() localVersion = soundcraft.__version__ if mgrVersion != localVersion: if not self._canShutdown() or not allowRestart: raise VersionIncompatibilityError( mgrVersion, self.servicePid(), localVersion ) else: self.restartService(mgrVersion, localVersion) self.ensureServiceVersion(allowRestart=False) def restartService(self, mgrVersion, localVersion): print( f"Restarting soundcraft dbus service ({self.servicePid()}) to upgrade {mgrVersion}->{localVersion}" ) self.shutdown() self.initManager() print(f"Restarted the service at {self.servicePid()}") def shutdown(self): loop = GLib.MainLoop() with self.serviceDisconnected.connect(loop.quit): self.manager.Shutdown() loop.run() serviceConnected = signal() serviceDisconnected = signal() def _nameChanged(self, busname, old, new): if busname != BUSNAME: return if old == "": print(f"New {busname} connected") self.serviceConnected() elif new == "": print(f"{busname} service disconnected") self.serviceDisconnected() def autodetect(self): devices = self.manager.devices if not devices: return None proxyDevice = self.bus.get(BUSNAME, devices[0]) self.deviceAdded(proxyDevice) return proxyDevice def waitForDevice(self): loop = GLib.MainLoop() with self.manager.Added.connect(lambda path: loop.quit()): loop.run() return self.autodetect() deviceAdded = signal() def _onAdded(self, path): proxyDevice = self.bus.get(BUSNAME, path) self.deviceAdded(proxyDevice) deviceRemoved = signal() def _onRemoved(self, path): self.deviceRemoved(path) def main(): parser = argparse.ArgumentParser() parser.add_argument( "--setup", help="Set up the dbus configuration in /usr/share/dbus-1 (Must be run as root)", action="store_true", ) parser.add_argument( "--uninstall", help="Remove any setup performed by --setup", action="store_true", ) args = parser.parse_args() if args.setup: setup() elif args.uninstall: uninstall() else: service = Service() service.run() if __name__ == "__main__": main() soundcraft-utils-0.4.0/soundcraft/gui.py000066400000000000000000000235541371544152300203450ustar00rootroot00000000000000# # Copyright (c) 2020 Jim Ramsay # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import sys import traceback from pathlib import Path from collections.abc import Iterable import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk from gi.repository import Gio import soundcraft from soundcraft.dbus import Client, DbusInitializationError, VersionIncompatibilityError def iconFile(): modulepaths = soundcraft.__path__ for path in modulepaths: png = Path(path) / "data" / "xdg" / "soundcraft-utils.png" if png.exists(): return str(png) return None class Main(Gtk.ApplicationWindow): def __init__(self, app): super().__init__(title="Soundcraft-utils", application=app) self.app = app icon = iconFile() if icon is not None: self.set_default_icon_from_file(icon) self.connect("destroy", self.app.quit_cb) self.grid = None self.dev = None self.setNoDevice() try: self.dbus = Client(added_cb=self.deviceAdded, removed_cb=self.deviceRemoved) except DbusInitializationError as e: print(f"Startup error: {str(e)}") self._startupFailure("Could not start soundcraft_gui", str(e)) raise e except Exception as e: print("Unexpected exception at gui startup") traceback.print_exc() self._startupFailure(f"Unexpected exception {e.__class__.__name__}", str(e)) raise e self.dbus.serviceDisconnected.connect(self.dbusDisconnect) self.dbus.serviceConnected.connect(self.dbusReconnect) def _startupFailure(self, title, message): dialog = Gtk.MessageDialog( parent=self, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=title, ) dialog.format_secondary_text(message) dialog.run() def dbusDisconnect(self): self.setNoDevice() def dbusReconnect(self): try: self.dbus.ensureServiceVersion() except VersionIncompatibilityError: self._startupFailure( "Dbus service version incompatibility", "Restart of this gui application is required", ) self.app.quit() # Todo: Can we relaunch ourselves? def setDevice(self, dev): if self.dev is not None: if self.dev._path == dev._path: # This already is our device return if self.grid is not None: self.remove(self.grid) self.dev = dev dev.onPropertiesChanged = self.reset self.grid = Gtk.Grid() self.add(self.grid) self.row = 0 self.addHeading(self.dev.name) self.addSep() for (targets, sources) in self.dev.fixedRouting: self.addRow(targets, sources) self.addSep() sourceData = Gtk.ListStore(str, str) for source in self.dev.sources.items(): sourceData.append([source[0], "\n".join(source[1])]) self.sourceCombo = Gtk.ComboBox(model=sourceData) renderer_text = Gtk.CellRendererText() self.sourceCombo.pack_start(renderer_text, True) self.sourceCombo.add_attribute(renderer_text, "text", 1) self.sourceCombo.connect("changed", self.selectionChanged) self.addRow(self.dev.routingTarget, self.sourceCombo) self.addActions() self.reset() self.show_all() def setNoDevice(self): self.dev = None if self.grid is not None: self.remove(self.grid) self.grid = Gtk.Grid() self.add(self.grid) self.row = 0 self.addHeading("No device found") self.show_all() def deviceAdded(self, dev): print(f"Added {dev._path}") self.setDevice(dev) def deviceRemoved(self, path): print(f"Removed {path}") if self.dev is not None: if self.dev._path != path: # Not our device return self.setNoDevice() def addHeading(self, text): section = Gtk.Label(label=None, margin=10, halign=Gtk.Align.START) section.set_markup(f"{text}") self.grid.attach(section, 0, self.row, 3, 1) self.row += 1 def _wrap_as_widget(self, item): if not isinstance(item, Gtk.Widget): if type(item) is not str and isinstance(item, Iterable): item = "\n".join(item) item = Gtk.Label(label=item) return item def addRow(self, left, right): left = self._wrap_as_widget(left) left.set_margin_top(10) left.set_margin_bottom(10) left.set_margin_start(10) left.set_margin_end(2) self.grid.attach(left, 0, self.row, 1, 2) img = Gtk.Image.new_from_icon_name("pan-start", Gtk.IconSize.BUTTON) img.set_valign(Gtk.Align.END) self.grid.attach(img, 1, self.row, 1, 1) img = Gtk.Image.new_from_icon_name("pan-start", Gtk.IconSize.BUTTON) img.set_valign(Gtk.Align.START) self.grid.attach(img, 1, self.row + 1, 1, 1) right = self._wrap_as_widget(right) right.set_margin_top(10) right.set_margin_bottom(10) right.set_margin_end(10) right.set_margin_start(2) right.set_halign(Gtk.Align.START) self.grid.attach(right, 2, self.row, 1, 2) self.row += 2 def addSep(self): self.grid.attach( Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), 0, self.row, 3, 1 ) self.row += 1 def addActions(self): self.actions = Gtk.ActionBar() self.grid.attach(self.actions, 0, self.row, 3, 1) self.applyButton = Gtk.Button.new_with_mnemonic("_Apply") self.resetButton = Gtk.Button.new_with_mnemonic("_Reset") self.actions.pack_end(self.applyButton) self.actions.pack_end(self.resetButton) self.resetButton.connect("clicked", self.reset) self.applyButton.connect("clicked", self.apply) def selectionChanged(self, comboBox): i = comboBox.get_active_iter() self.nextSelection = comboBox.get_model()[i][0] self.setActionsEnabled(self.nextSelection != self.dev.routingSource) def apply(self, button=None): print(f"Setting routing source to {self.nextSelection}") self.dev.routingSource = self.nextSelection self.setActionsEnabled(False) def reset(self, button=None, *args, **kwargs): for (i, source) in enumerate(self.dev.sources.items()): if self.dev.routingSource == source[0]: self.sourceCombo.set_active(i) self.setActionsEnabled(False) def setActionsEnabled(self, enabled): self.applyButton.set_sensitive(enabled) self.resetButton.set_sensitive(enabled) class About(Gtk.AboutDialog): def __init__(self): super().__init__( program_name="soundcraft-utils", version=soundcraft.__version__, comments="Linux Utilities for Soundcraft Mixers", license_type=Gtk.License.MIT_X11, website="https://github.com/lack/soundcraft-utils", website_label="Github page", authors=[ "Jim Ramsay - Author", "Christoph - Testing and suggestions", "Pete Merges - Notepad-8FX support and testing", "Viktor Mastoridis - Notepad-5 support and testing", ], artists=["Flat Icons https://www.flaticon.com/authors/flat-icons"], ) self.connect("response", self.close_cb) def close_cb(self, action, parameter): action.close() class App(Gtk.Application): def __init__(self): super().__init__(application_id="soundcraft.utils") self.window = None def do_activate(self): if self.window is not None: return try: self.window = Main(self) except DbusInitializationError: self.quit() except Exception: print("Unexpected exception at gui startup") traceback.print_exc() self.quit() def about_cb(self, action, parameter): about = About() about.show() def quit_cb(self, *args, **kwargs): self.quit() def do_startup(self): Gtk.Application.do_startup(self) self.set_app_menu(Gio.Menu()) self.addAppmenu("About", self.about_cb) self.addAppmenu("Quit", self.quit_cb) def addAppmenu(self, name, cb): actionName = name.lower() self.get_app_menu().append(name, f"app.{actionName}") action = Gio.SimpleAction(name=actionName) action.connect("activate", cb) self.add_action(action) def main(): app = App() sys.exit(app.run(sys.argv)) if __name__ == "__main__": main() soundcraft-utils-0.4.0/soundcraft/notepad.py000066400000000000000000000154761371544152300212170ustar00rootroot00000000000000# # Copyright (c) 2020 Jim Ramsay # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import array import enum import json import os import usb.core DEFAULT_STATEDIR = "/var/lib/soundcraft-utils" HARMAN_USB = 0x05FC def autodetect(stateDir=DEFAULT_STATEDIR): for devType in ("12fx", "8fx", "5"): dev = eval(f"Notepad_{devType}(stateDir=stateDir)") if dev.found(): return dev class NotepadBase: def __init__( self, idProduct, routingTarget, stateDir=DEFAULT_STATEDIR, fixedRouting=[], ): self.routingTarget = routingTarget self.fixedRouting = fixedRouting self.stateDir = stateDir self.dev = usb.core.find(idVendor=HARMAN_USB, idProduct=idProduct) if self.dev is not None: major = self.dev.bcdDevice >> 8 minor = self.dev.bcdDevice & 0xFF try: self.product = self.dev.product except Exception: # Fall-back to class name, since reading the product over USB requires write access self.product = self.__class__.__name__ self.fwVersion = f"{major}.{minor}" self.stateFile = f"{stateDir}/{self.product}.state" self.state = {} self._loadState() def found(self): return self.dev is not None def resetState(self): storedSource = self.routingSource if storedSource == "UNKNOWN": return self.routingSource = storedSource @property def routingSource(self): if "source" not in self.state: return "UNKNOWN" return self.Sources(self.state["source"]).name @routingSource.setter def routingSource(self, request): assert self.found() source = self._parseSourcename(request) if source is None: raise ValueError(f"Requested input {request} is not a valid choice") print(f"Switching USB audio input to {source.name}") # Reverse engineered via Wireshark on Windows # 0 => 0x00 00 04 00 00 00 00 00 # 1 => 0x00 00 04 00 01 00 00 00 # Change this -^ message = array.array("B", [0x00, 0x00, 0x04, 0x00, source, 0x00, 0x00, 0x00]) print(f"Sending {message}") self.dev.ctrl_transfer(0x40, 16, 0, 0, message) self.state["source"] = source self._saveState() @property def sources(self): return {x.name: self.Label[x] for x in self.Sources} @property def name(self): return f"{self.product} (fw v{self.fwVersion})" def fetchInfo(self): assert self.found() # TODO: Decode these? # Unfortunately, inspection shows none of the data here # corresponds to thr current source selection self.info1 = self.dev.ctrl_transfer(0xA1, 1, 0x0100, 0x2900, 256) self.info2 = self.dev.ctrl_transfer(0xA1, 2, 0x0100, 0x2900, 256) def _parseSourcename(self, request): sources = self.Sources if isinstance(request, self.Sources): return request if isinstance(request, int): return sources(request) try: num = int(request) return self._parseSourcename(num) except ValueError: try: return sources[request] except KeyError: for source in sources: # This could be better; maybe ensure it's a unique substring? if str(request) in source.name: return source except Exception: pass return None def _saveState(self): try: os.makedirs(self.stateDir, exist_ok=True) with open(self.stateFile, "w") as fh: fh.write(json.dumps(self.state, sort_keys=True, indent=4)) except Exception as e: print(f"Warning: Could not write state file: {e}") def _loadState(self): try: with open(self.stateFile, "r") as fh: self.state = json.loads(fh.read()) except Exception: pass def stereo_label(base): return (f"{base} L", f"{base} R") class Notepad_12fx(NotepadBase): def __init__(self, **kwargs): super().__init__( idProduct=0x0032, routingTarget=("capture_3", "capture_4"), fixedRouting=[(("capture_1", "capture_2"), ("Mic/Line 1", "Mic/Line 2"))], **kwargs, ) class Sources(enum.IntEnum): INPUT_3_4 = 0 INPUT_5_6 = 1 INPUT_7_8 = 2 MASTER_L_R = 3 Label = { Sources.INPUT_3_4: ("Mic/Line 3", "Mic/Line 4"), Sources.INPUT_5_6: stereo_label("Stereo 5/6"), Sources.INPUT_7_8: stereo_label("Stereo 7/8"), Sources.MASTER_L_R: stereo_label("Mix"), } class Notepad_8fx(NotepadBase): def __init__(self, **kwargs): super().__init__( idProduct=0x0031, routingTarget=("capture_1", "capture_2"), **kwargs ) class Sources(enum.IntEnum): INPUT_1_2 = 0 INPUT_3_4 = 1 INPUT_5_6 = 2 MASTER_L_R = 3 Label = { Sources.INPUT_1_2: ("Mic/Line 1", "Mic/Line 2"), Sources.INPUT_3_4: stereo_label("Stereo 3/4"), Sources.INPUT_5_6: stereo_label("Stereo 5/6"), Sources.MASTER_L_R: stereo_label("Mix"), } class Notepad_5(NotepadBase): def __init__(self, **kwargs): super().__init__( idProduct=0x0030, routingTarget=("capture_1", "capture_2"), **kwargs ) class Sources(enum.IntEnum): MONO_1_MONO_2 = 0 STEREO_2_3 = 1 STEREO_4_5 = 2 MASTER_L_R = 3 Label = { Sources.MONO_1_MONO_2: ("Mic/Line 1", "Mono Line 2"), Sources.STEREO_2_3: stereo_label("Stereo 2/3"), Sources.STEREO_4_5: stereo_label("Stereo 4/5"), Sources.MASTER_L_R: stereo_label("Mix"), } soundcraft-utils-0.4.0/test/000077500000000000000000000000001371544152300160055ustar00rootroot00000000000000soundcraft-utils-0.4.0/test/test_notepad.py000066400000000000000000000053501371544152300210530ustar00rootroot00000000000000import array from unittest.mock import DEFAULT, MagicMock, patch import pytest import usb.core from soundcraft import notepad class UsbCoreMock: def __init__(self): self.mockUsb = {} def find(self, idVendor, idProduct): if idVendor != 0x05FC: return None return self.mockUsb.get(idProduct, None) def setupDevice(self, id): self.mockUsb[id] = DEFAULT @pytest.fixture def usbCore(): mock = UsbCoreMock() usb.core.find = MagicMock(side_effect=mock.find) return mock def test_autodetect_none(usbCore, tmpdir): dev = notepad.autodetect(tmpdir) assert dev is None @pytest.mark.parametrize( "id, expected", [ (0x0032, notepad.Notepad_12fx), (0x0031, notepad.Notepad_8fx), (0x0030, notepad.Notepad_5), ], ) def test_autodetect_single(usbCore, tmpdir, id, expected): usbCore.setupDevice(id) dev = notepad.autodetect(stateDir=tmpdir) assert isinstance(dev, expected) @patch("usb.core.find", return_value=None) def test_notepad_notfound(find, tmpdir): dev = notepad.Notepad_12fx(stateDir=tmpdir) assert not dev.found() with pytest.raises(AssertionError): dev.fetchInfo() with pytest.raises(AssertionError): dev.routingSource = "Anything" def messageFor(i): # Reverse engineered via Wireshark on Windows # 0 => 0x00 00 04 00 00 00 00 00 # 1 => 0x00 00 04 00 01 00 00 00 # Change this -^ return array.array("B", [0x00, 0x00, 0x04, 0x00, i, 0x00, 0x00, 0x00]) @pytest.mark.parametrize( "desiredSource, expectedCtrlMessage", [ (0, messageFor(0)), (1, messageFor(1)), (2, messageFor(2)), (3, messageFor(3)), ("INPUT_3_4", messageFor(0)), ("INPUT_5_6", messageFor(1)), ("INPUT_7_8", messageFor(2)), ("MASTER_L_R", messageFor(3)), ("5_6", messageFor(1)), (notepad.Notepad_12fx.Sources.INPUT_7_8, messageFor(2)), ], ) @patch("usb.core.find") def test_notepad_statesave(find, desiredSource, expectedCtrlMessage, tmpdir): usbdev = find.return_value usbdev.product = "TestNotepad" dev = notepad.Notepad_12fx(stateDir=tmpdir) assert dev.found() assert dev.routingSource == "UNKNOWN" dev.routingSource = desiredSource usbdev.ctrl_transfer.assert_called_with(0x40, 16, 0, 0, expectedCtrlMessage) dev2 = notepad.Notepad_12fx(stateDir=tmpdir) assert dev2.routingSource == dev.routingSource @pytest.mark.parametrize("input", ["bad", -1, 512, "master_l_r", "INPUT_1_2", None]) @patch("usb.core.find") def test_notepad_badsource(find, input, tmpdir): dev = notepad.Notepad_12fx(stateDir=tmpdir) assert dev.found() with pytest.raises(ValueError): dev.routingSource = input soundcraft-utils-0.4.0/tools/000077500000000000000000000000001371544152300161665ustar00rootroot00000000000000soundcraft-utils-0.4.0/tools/contrib_to_about.py000077500000000000000000000033721371544152300221040ustar00rootroot00000000000000#!/bin/env python3 import re import os author_format = re.compile( r"^- \[(?P[^]]+)]\(mailto:(?P[^)]+)\)(?P.*)" ) link_format = re.compile(r"\[(?P[^]]+)]\((?P[^)]+)\)") def parseMarkdown(line): author_match = author_format.match(line) if author_match is not None: author = author_match.groupdict() return f"{author['name']} <{author['email']}>{author['description']}" link_match = link_format.search(line) if link_match is not None: link = link_match.groupdict() return f"{link['name']} {link['url']}" return line.lstrip("- ") contributors = {} section = None with open("CONTRIBUTORS.md") as contrib: for line in contrib: line = line.rstrip("\n") if line.startswith("- "): contributors[section].append(parseMarkdown(line)) elif line.startswith("--") or len(line) == 0: next else: section = line contributors[section] = [] print(f"authors={contributors['Contributors']}") print(f"artists={contributors['Artwork']}") gui = "soundcraft/gui.py" edited = f"{gui}.edited" print(f"Editing {gui}") with open(gui) as src, open(edited, "w") as dst: mode = "" for line in src: if "authors=[" in line: mode = "authors" elif "artists=[" in line: mode = "artists" if mode == "authors": if "]," in line: dst.write(f"authors={contributors['Contributors']},\n") mode = "" elif mode == "artists": if "]," in line: dst.write(f"artists={contributors['Artwork']},\n") mode = "" else: dst.write(line) os.rename(edited, gui) os.system(f"black {gui}") soundcraft-utils-0.4.0/tools/dist.sh000077500000000000000000000005541371544152300174740ustar00rootroot00000000000000#!/bin/bash if [[ ! -d .git ]]; then echo "$0 must be run from the project root" exit 1 fi rm -rf dist build soundcraft_utils.egg-info if [[ $VERSION_SUFFIX ]]; then echo "Overriding version with $VERSION_SUFFIX" EGG_INFO="egg_info -b $VERSION_SUFFIX" fi python3 setup.py $EGG_INFO sdist bdist_wheel || exit 1 [[ -f $BAK ]] && mv $BAK $ORIG exit 0 soundcraft-utils-0.4.0/tools/release.sh000077500000000000000000000005101371544152300201410ustar00rootroot00000000000000#!/bin/bash TOOLS=$(dirname $0) $TOOLS/dist.sh || exit 1 echo ”----------------------------------------” ls dist echo -n "About to release; are you sure? [y/N] " read answer if [[ ! $answer =~ ^[yY] ]]; then echo "Exiting" exit 1 fi python3 -m twine upload --user __token__ --password $(<$TOOLS/pypi.realkey) dist/* soundcraft-utils-0.4.0/tools/testupload.sh000077500000000000000000000006721371544152300207160ustar00rootroot00000000000000#!/bin/bash TOOLS=$(dirname $0) export VERSION_SUFFIX=$1 if [[ ! $VERSION_SUFFIX ]]; then echo "usage: $0 [a1|b1|rc1]" exit 1 fi if [[ ! $VERSION_SUFFIX =~ ^(a|b|rc)[0-9]+$ ]]; then echo "Suffix must be one of:" echo " a2" echo " b5" echo " rc1" exit 2 fi $TOOLS/dist.sh || exit 1 python3 -m twine upload --user __token__ --password $(<$TOOLS/pypi.testkey) --repository-url https://test.pypi.org/legacy/ dist/* soundcraft-utils-0.4.0/tox.ini000066400000000000000000000001371371544152300163420ustar00rootroot00000000000000[tox] #envlist = py36,py38 envlist = py3 [testenv] deps = pytest commands = pytest {posargs}