Music production session manager
https://www.laborejo.org
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
12 KiB
288 lines
12 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
|
|
|
|
This application is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
#This is the first file in the program to be actually executed, after the executable which uses this as first instruction.
|
|
|
|
"""
|
|
We use a 'wrong' scheme of importing modules here because there are multiple exit conditions, good and bad.
|
|
We don't want to use all the libraries, including the big Qt one, only to end up displaying the --version and exit.
|
|
Same with the tests if jack or nsm are running.
|
|
"""
|
|
|
|
#Give at least some feedback when C libs crash.
|
|
#Will still not work for the common case that PyQt crashes and ends Python.
|
|
#But every bit helps when hunting bugs.
|
|
import faulthandler; faulthandler.enable()
|
|
|
|
|
|
from engine.config import * #includes METADATA only. No other environmental setup is executed.
|
|
from qtgui.helper import setPaletteAndFont #our error boxes shall look like the rest of the program
|
|
|
|
"""
|
|
Check parameters first. It is possible that we will just --help or --version and exit. In this case
|
|
nothing gets loaded.
|
|
"""
|
|
import os.path
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description=f"""{METADATA["name"]} - Version {METADATA["version"]} - Copyright {METADATA["year"]} by {METADATA["author"]} - {METADATA["url"]}""")
|
|
parser.add_argument("-v", "--version", action='version', version="{} {}".format(METADATA["name"], METADATA["version"]))
|
|
parser.add_argument("-V", "--verbose", action='store_true', help="(Development) Switch the logger to INFO and print out all kinds of information to get a high-level idea of what the program is doing. You can also set the environment variable LSS_DEBUG=1")
|
|
|
|
parser.add_argument("-u", "--url", action='store', dest="url", help="Force URL for the session. If there is already a running session we will connect to it. Otherwise we will start one there. Default is local host with random port. Example: osc.udp://myhost.localdomain:14294/")
|
|
parser.add_argument("--nsm-url", action='store', dest="url", help="Same as --url.")
|
|
parser.add_argument("-l", "--load-session", action='store', dest="session", help="Session to open on startup, must exist. Overrides --continue")
|
|
parser.add_argument("-c", "--continue", action='store_true', dest="continueLastSession", help="Autostart last active session.")
|
|
parser.add_argument("-i", "--hide", action='store_true', dest="starthidden", help="Start GUI hidden in tray, only if tray available on system.")
|
|
parser.add_argument("--session-root", action='store', dest="sessionRoot", help="Root directory of all sessions. Defaults to $XDG_DATA_HOME/nsm/")
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
#Check for an alternative way to enable the logger.
|
|
import os
|
|
if args.verbose:
|
|
os.environ["LSS_DEBUG"] = "1" #for children programs.
|
|
elif not args.verbose and os.getenv("LSS_DEBUG"):
|
|
args.verbose = True
|
|
|
|
|
|
import logging
|
|
if args.verbose: #development
|
|
logging.basicConfig(level=logging.INFO, format='[' + METADATA["shortName"] + '] %(levelname)s %(asctime)s %(name)s: %(message)s',)
|
|
#logging.getLogger().setLevel(logging.INFO) #development
|
|
else: #production
|
|
logging.basicConfig(level=logging.ERROR, format='[' + METADATA["shortName"] + '] %(levelname)s %(asctime)s %(name)s: %(message)s',)
|
|
#logging.getLogger().setLevel(logging.ERROR) #production
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger.info("import")
|
|
|
|
"""set up python search path before the program starts
|
|
We need to be earliest, so let's put it here.
|
|
This is influence during compiling by creating a temporary file "compiledprefix.py".
|
|
Nuitka complies that in, when make is finished we delete it.
|
|
|
|
#Default mode is a self-contained directory relative to the uncompiled patroneo python start script
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import os.path
|
|
from PyQt5.QtWidgets import QApplication, QStyleFactory
|
|
|
|
|
|
logger.info(f"Python Version {sys.version}")
|
|
|
|
try:
|
|
from compiledprefix import prefix
|
|
compiledVersion = True
|
|
logger.info("Compiled prefix found: {}".format(prefix))
|
|
except ModuleNotFoundError as e:
|
|
compiledVersion = False
|
|
|
|
logger.info("Compiled version: {}".format(compiledVersion))
|
|
|
|
if compiledVersion:
|
|
PATHS={ #this gets imported
|
|
"root": "",
|
|
"bin": os.path.join(prefix, "bin"),
|
|
"doc": os.path.join(prefix, "share", "doc", METADATA["shortName"]),
|
|
"desktopfile": os.path.join(prefix, "share", "applications", METADATA["shortName"] + ".desktop"), #not ~/Desktop but our desktop file
|
|
"share": os.path.join(prefix, "share", METADATA["shortName"]),
|
|
"templateShare": os.path.join(prefix, "share", METADATA["shortName"], "template"),
|
|
"sessionRoot": args.sessionRoot,
|
|
"url": args.url,
|
|
"startupSession": args.session,
|
|
"startHidden": args.starthidden, #bool
|
|
"continueLastSession": args.continueLastSession, #bool
|
|
}
|
|
_root = os.path.dirname(__file__)
|
|
_root = os.path.abspath(os.path.join(_root, ".."))
|
|
|
|
else:
|
|
_root = os.path.dirname(__file__)
|
|
_root = os.path.abspath(os.path.join(_root, ".."))
|
|
PATHS={ #this gets imported
|
|
"root": _root,
|
|
"bin": _root,
|
|
"doc": os.path.join(_root, "documentation", "out"),
|
|
"desktopfile": os.path.join(_root, "desktop", "desktop.desktop"), #not ~/Desktop but our desktop file
|
|
"share": os.path.join(_root, "engine", "resources"),
|
|
"templateShare": os.path.join(_root, "template", "engine", "resources"),
|
|
"sessionRoot": args.sessionRoot,
|
|
"url": args.url,
|
|
"startupSession": args.session,
|
|
"startHidden": args.starthidden, #bool
|
|
"continueLastSession": args.continueLastSession, #bool
|
|
}
|
|
|
|
if PATHS["startupSession"]:
|
|
logger.warning("--continue ignored because --load-session was used.")
|
|
PATHS["continueLastSession"] = None #just in case. See --help string
|
|
|
|
logger.info("PATHS: {}".format(PATHS))
|
|
|
|
#Construct QAppliction before constantsAndCOnfigs, which has the fontDB
|
|
#QApplication.setDesktopSettingsAware(False) #We need our own font so the user interface stays predictable
|
|
QApplication.setDesktopFileName(PATHS["desktopfile"])
|
|
qtApp = QApplication(sys.argv)
|
|
setPaletteAndFont(qtApp)
|
|
QApplication.setStyle(QStyleFactory.create("Fusion"))
|
|
setPaletteAndFont(qtApp)
|
|
|
|
|
|
def exitWithMessage(message:str):
|
|
title = f"""{METADATA["name"]} Error"""
|
|
if sys.stdout.isatty():
|
|
sys.exit(title + ": " + message)
|
|
else:
|
|
from PyQt5.QtWidgets import QMessageBox
|
|
#This is the start file for the Qt client so we know at least that Qt is installed and use that for a warning.
|
|
QMessageBox.critical(qtApp.desktop(), title, message)
|
|
sys.exit(title + ": " + message)
|
|
|
|
def setProcessName(executableName):
|
|
"""From
|
|
https://stackoverflow.com/questions/31132342/change-process-name-while-executing-a-python-script
|
|
"""
|
|
import ctypes, ctypes.util
|
|
lib = ctypes.cdll.LoadLibrary(None)
|
|
prctl = lib.prctl
|
|
prctl.restype = ctypes.c_int
|
|
prctl.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_ulong,
|
|
ctypes.c_ulong, ctypes.c_ulong]
|
|
def set_proctitle(new_title):
|
|
result = prctl(15, new_title, 0, 0, 0)
|
|
if result != 0:
|
|
raise OSError("prctl result: %d" % result)
|
|
set_proctitle(executableName.encode())
|
|
|
|
|
|
libpthread_path = ctypes.util.find_library("pthread")
|
|
if not libpthread_path:
|
|
return
|
|
|
|
libpthread = ctypes.CDLL(libpthread_path)
|
|
if hasattr(libpthread, "pthread_setname_np"):
|
|
_pthread_setname_np = libpthread.pthread_setname_np
|
|
|
|
_pthread_self = libpthread.pthread_self
|
|
_pthread_self.argtypes = []
|
|
_pthread_self.restype = ctypes.c_void_p
|
|
|
|
_pthread_setname_np.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
|
_pthread_setname_np.restype = ctypes.c_int
|
|
|
|
if _pthread_setname_np is None:
|
|
return
|
|
|
|
_pthread_setname_np(_pthread_self(), executableName.encode())
|
|
|
|
|
|
def _is_jack_running():
|
|
"""Check for JACK"""
|
|
import ctypes
|
|
import os
|
|
|
|
if not args.verbose:
|
|
silent = os.open(os.devnull, os.O_WRONLY)
|
|
stdout = os.dup(1)
|
|
stderr = os.dup(2)
|
|
os.dup2(silent, 1) #stdout
|
|
os.dup2(silent, 2) #stderr
|
|
cjack = ctypes.cdll.LoadLibrary("libjack.so.0")
|
|
class jack_client_t(ctypes.Structure):
|
|
_fields_ = []
|
|
cjack.jack_client_open.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int)] #the two ints are enum and pointer to enum. #http://jackaudio.org/files/docs/html/group__ClientFunctions.html#gab8b16ee616207532d0585d04a0bd1d60
|
|
cjack.jack_client_open.restype = ctypes.POINTER(jack_client_t)
|
|
ctypesJackClient = cjack.jack_client_open("probe".encode("ascii"), 0x01, None) #0x01 is the bit set for do not autostart JackNoStartServer
|
|
try:
|
|
ret = bool(ctypesJackClient.contents)
|
|
except ValueError: #NULL pointer access
|
|
ret = False
|
|
cjack.jack_client_close(ctypesJackClient)
|
|
if not args.verbose:
|
|
os.dup2(stdout, 1) #stdout
|
|
os.dup2(stderr, 2) #stderr
|
|
return ret
|
|
|
|
def checkJackOrExit(prettyName):
|
|
import sys
|
|
if not _is_jack_running():
|
|
exitWithMessage("JACK Audio Connection Kit is not running. Please start it.")
|
|
|
|
|
|
def isAnotherAgordejoInstanceRunning()->bool:
|
|
import socket
|
|
tempSocket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
# This is for the first agordejo instance, when no other is running. We set up
|
|
# a socket and listen throughout the runtime of our instance.
|
|
#
|
|
# Create an abstract socket, by prefixing it with null.
|
|
# this relies on a feature only in linux, when current process quits, the
|
|
# socket will be deleted.
|
|
tempSocket.bind('\0' + "agordejo")
|
|
tempSocket.listen(1)
|
|
tempSocket.setblocking(False)
|
|
return False
|
|
except socket.error:
|
|
# This is for the 2nd agordejo instance that has detected there is already another
|
|
# instance running. We will exit here before anything related to NSM has happened.
|
|
tempSocket.connect('\0' + "agordejo")
|
|
tempSocket.send("agordejoactivate".encode());
|
|
tempSocket.close()
|
|
return True
|
|
|
|
|
|
def checkAgordejoOrExit():
|
|
if (not args.url) and isAnotherAgordejoInstanceRunning():
|
|
exitWithMessage("Another Agordejo instance is already running. Informing it of our start attempt.")
|
|
|
|
checkAgordejoOrExit()
|
|
checkJackOrExit(METADATA["name"])
|
|
|
|
try:
|
|
#Only cosmetics
|
|
setProcessName(METADATA["shortName"])
|
|
except:
|
|
pass
|
|
|
|
|
|
#Capture Ctlr+C / SIGINT and let @atexit handle the rest.
|
|
import signal
|
|
import sys
|
|
def signal_handler(sig, frame):
|
|
sys.exit(0) #atexit will trigger
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
|
|
#Catch Exceptions even if PyQt crashes.
|
|
import sys
|
|
sys._excepthook = sys.excepthook
|
|
def exception_hook(exctype, value, traceback):
|
|
"""This hook purely exists to call sys.exit(1) even on a Qt crash
|
|
so that atexit gets triggered"""
|
|
#print(exctype, value, traceback)
|
|
logger.error("Caught crash in execpthook. Trying too execute atexit anyway")
|
|
sys._excepthook(exctype, value, traceback)
|
|
sys.exit(1)
|
|
sys.excepthook = exception_hook
|
|
|