An open source DAW for GNU/Linux, inspired by modular synths.
http://noisicaa.odahoda.de/
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.
406 lines
16 KiB
406 lines
16 KiB
# @begin:license |
|
# |
|
# Copyright (c) 2015-2021, Ben Niemann <pink@odahoda.de> |
|
# |
|
# This program 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 2 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, write to the Free Software Foundation, Inc., |
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
|
# |
|
# @end:license |
|
|
|
import argparse |
|
import asyncio |
|
import functools |
|
import os |
|
import os.path |
|
import signal |
|
import sys |
|
import time |
|
from typing import cast, List |
|
|
|
from PySide2.QtCore import Qt |
|
from PySide2 import QtCore |
|
from PySide2 import QtGui |
|
from PySide2 import QtQml |
|
import flatbuffers |
|
import qasync |
|
|
|
import noisicaa |
|
from noisicaa import config |
|
from noisicaa import logging |
|
from noisicaa import model |
|
from noisicaa import engine as engine_lib |
|
from noisicaa.node_lib import ui_registry |
|
from noisicaa.node_lib import model_registry |
|
from . import node_db |
|
from . import qml_registry |
|
from .DeviceDB import DeviceDB, Device |
|
from .App import App |
|
|
|
logger = None # type: logging.Logger |
|
|
|
|
|
class QEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore |
|
def __init__(self, qtApp: QtCore.QCoreApplication) -> None: |
|
super().__init__() |
|
self.__qtApp = qtApp |
|
|
|
def new_event_loop(self) -> asyncio.AbstractEventLoop: |
|
return qasync.QEventLoop(self.__qtApp) |
|
|
|
|
|
class MainApp(App): |
|
def __init__(self) -> None: |
|
super().__init__() |
|
|
|
self.__path = None # type: str |
|
self.__logLevel = None # type: str |
|
self.__dataDir = None # type: str |
|
self.__libDir = None # type: str |
|
|
|
self.__exit_code = 0 |
|
self.__quit = None # type: asyncio.Event |
|
self.__qtApp = None # type: QtGui.QGuiApplication |
|
self.__settings = None # type: QtCore.QSettings |
|
self.__classRegistry = None # type: model.ClassRegistry |
|
|
|
self.__renderLoad = [] # type: List[float] |
|
self.__renderSampleStart = None # type: float |
|
self.__renderBusyTime = 0 |
|
self.__renderStartTime = None # type: int |
|
|
|
@property |
|
def dataDir(self) -> str: |
|
return self.__dataDir |
|
|
|
@property |
|
def qtApp(self) -> QtGui.QGuiApplication: |
|
return self.__qtApp |
|
|
|
@property |
|
def classRegistry(self) -> model.ClassRegistry: |
|
return self.__classRegistry |
|
|
|
@property |
|
def settings(self) -> QtCore.QSettings: |
|
return self.__settings |
|
|
|
def run(self, argv: List[str]) -> int: |
|
self.__parse_args(argv) |
|
|
|
with logging.LogManager(self.__logLevel): |
|
global logger # pylint: disable=global-statement |
|
logger = logging.getLogger('noisicaa.ui.main') |
|
logger.info("Starting noisicaa UI...") |
|
|
|
os.environ['QT_LOGGING_RULES'] = 'qt.scenegraph.general=true' |
|
|
|
self.__qtApp = QtGui.QGuiApplication(argv) |
|
self.__qtApp.setQuitOnLastWindowClosed(False) |
|
self.__qtApp.setOrganizationName("odahoda") |
|
self.__qtApp.setOrganizationDomain("odahoda.de") |
|
self.__qtApp.setApplicationName("Noisicaa") |
|
self.__qtApp.setApplicationVersion(config.VERSION) |
|
|
|
asyncio.set_event_loop_policy(QEventLoopPolicy(self.__qtApp)) |
|
return asyncio.run(self.__runAsync()) |
|
|
|
async def __runAsync(self) -> int: |
|
loop = asyncio.get_event_loop() |
|
for sig in (signal.SIGINT, signal.SIGTERM): |
|
loop.add_signal_handler( |
|
sig, functools.partial(self.__handle_signal, sig)) |
|
|
|
QtCore.QDir.setSearchPaths('icons', [os.path.join(self.dataDir, 'icons')]) |
|
self.__qtApp.setWindowIcon(QtGui.QIcon('icons:noisicaa.svg')) |
|
|
|
self.__settings = QtCore.QSettings(self.__qtApp) |
|
|
|
with self.settingsGroup('display') as settings: |
|
self.displayMultisampling = (settings.value('multisampling', 'true') == 'true') |
|
self.displayMultisamplingChanged.connect(self.__displayMultisamplingChanged) |
|
self.displayStyle = str(settings.value('style', 'Material')) |
|
self.displayStyleChanged.connect(self.__displayStyleChanged) |
|
self.displayMaterialTheme = str(settings.value('materialTheme', 'System')) |
|
self.displayMaterialThemeChanged.connect(self.__displayMaterialThemeChanged) |
|
self.displayMaterialVariant = str(settings.value('materialVariant', 'Normal')) |
|
self.displayMaterialVariantChanged.connect(self.__displayMaterialVariantChanged) |
|
|
|
if self.displayMultisampling: |
|
logger.info("Enable multisampling.") |
|
format = QtGui.QSurfaceFormat(QtGui.QSurfaceFormat.defaultFormat()) |
|
format.setSamples(16) |
|
QtGui.QSurfaceFormat.setDefaultFormat(format) |
|
|
|
os.environ["QT_QUICK_CONTROLS_STYLE"] = self.displayStyle |
|
os.environ["QT_QUICK_CONTROLS_MATERIAL_VARIANT"] = self.displayMaterialVariant |
|
|
|
self._qmlEngine = QtQml.QQmlEngine(self.__qtApp) |
|
self._qmlEngine.warnings.connect(self.__qmlEngineWarnings) |
|
self._qmlEngine.setOutputWarningsToStandardError(False) |
|
self._qmlEngine.addImportPath( |
|
os.path.abspath(os.path.join(os.path.dirname(noisicaa.__file__), '..'))) |
|
|
|
qml_registry.registerTypes() |
|
self._modelRegistry = model_registry.ModelRegistry() |
|
self._uiRegistry = ui_registry.UIRegistry() |
|
|
|
self.__classRegistry = model.ClassRegistry() |
|
for cls in self._modelRegistry.classes.values(): |
|
self.__classRegistry.add(cls) |
|
for cls in self._modelRegistry.extraClasses: |
|
self.__classRegistry.add(cls) |
|
|
|
self.__quit = asyncio.Event() |
|
|
|
try: |
|
self._deviceDB = DeviceDB(self) |
|
|
|
logger.info("Initialize engine...") |
|
self._engine = engine_lib.Engine() |
|
self._engine.notifications.add(self.__handleEngineNotification) |
|
|
|
self.engineMasterVolume = float(str(settings.value('masterVolume', '1.0'))) |
|
|
|
logger.info("Initialize node db...") |
|
builder = flatbuffers.Builder(1024) |
|
builder.Finish(engine_lib.CreateNodeDBSettings( |
|
builder, |
|
search_path_builtins=[os.path.join(self.__libDir, 'noisicaa/node_lib')])) |
|
self._nodeDB = node_db.NodeDB(builder.Output(), self.__qtApp) |
|
await self._nodeDB.setup() |
|
|
|
logger.info("Configure backend...") |
|
self.updateAudioBackend() |
|
|
|
self.needsRestart = False |
|
self.needsRestartChanged.connect(functools.partial(logger.error, "needs restart %s")) |
|
|
|
recentProjects = settings.value('recentProjects', []) |
|
if isinstance(recentProjects, str): |
|
recentProjects = [recentProjects] |
|
assert isinstance(recentProjects, list) |
|
self._recentProjects.setPaths(recentProjects) |
|
|
|
if self.__path is not None: |
|
self.openProject(self.__path) |
|
else: |
|
self.newProject() |
|
|
|
self._projectWindowComponent = self.createQmlComponent( |
|
os.path.join(os.path.dirname(__file__), 'ProjectWindow.qml')) |
|
self._window = self._projectWindowComponent.createWithInitialProperties( |
|
{'d': self._project}) |
|
self.__startRenderPerformanceMonitor() |
|
|
|
with self.settingsGroup('window/mainwindow') as settings: |
|
if settings.value('fullscreen', 'false') == 'true': |
|
self._window.showFullScreen() |
|
elif settings.value('maximized', 'false') == 'true': |
|
self._window.showMaximized() |
|
else: |
|
x = cast(str, settings.value('x')) |
|
y = cast(str, settings.value('y')) |
|
if x is not None and y is not None: |
|
self._window.setPosition(int(x), int(y)) |
|
width = cast(str, settings.value('width')) |
|
height = cast(str, settings.value('height')) |
|
if width is not None and height is not None: |
|
self._window.resize(int(width), int(height)) |
|
self._window.show() |
|
|
|
self._project.restartEngine() |
|
|
|
await self.__quit.wait() |
|
logger.info("Closing UI...") |
|
|
|
finally: |
|
if self._qmlEngine is not None: |
|
# Delete the QQmlEngine before the QML properties are cleared out, which triggers a |
|
# whole bunch of warning messages. See also |
|
# https://bugreports.qt.io/browse/QTBUG-81247 |
|
self._qmlEngine.deleteLater() |
|
await asyncio.sleep(0) |
|
|
|
if self._window is not None: |
|
with self.settingsGroup('window/mainwindow') as settings: |
|
geometry = self._window.geometry() |
|
settings.setValue('x', geometry.x()) |
|
settings.setValue('y', geometry.y()) |
|
settings.setValue('width', geometry.width()) |
|
settings.setValue('height', geometry.height()) |
|
states = self._window.windowStates() |
|
settings.setValue('maximized', bool(states & Qt.WindowMaximized)) |
|
settings.setValue('fullscreen', bool(states & Qt.WindowFullScreen)) |
|
|
|
self._window.close() |
|
self._window = None |
|
|
|
if self._project is not None: |
|
self._project.cleanup() |
|
|
|
if self._engine is not None: |
|
self._engine.stop() |
|
|
|
if self._nodeDB is not None: |
|
await self._nodeDB.cleanup() |
|
|
|
return self.__exit_code |
|
|
|
def __parse_args(self, argv: List[str]) -> None: |
|
defaultDataDir = config.DATA_DIR |
|
defaultLibDir = config.LIB_DIR |
|
assert defaultLibDir.startswith('/') |
|
if 'APPDIR' in os.environ: |
|
assert defaultDataDir.startswith('/') |
|
defaultDataDir = os.path.join(os.environ['APPDIR'], defaultDataDir[1:]) |
|
assert defaultLibDir.startswith('/') |
|
defaultLibDir = os.path.join(os.environ['APPDIR'], defaultLibDir[1:]) |
|
|
|
parser = argparse.ArgumentParser( |
|
prog=argv[0]) |
|
parser.add_argument( |
|
'--log-level', |
|
metavar='LEVEL', |
|
default='error', |
|
help=("Minimum level for log messages written to STDERR. A comma separated list" |
|
" of logger=level pairs, where 'logger' is the name of a logger and 'level'" |
|
" one of 'debug', 'info', 'warning', 'error', 'critical'. A bare 'level'" |
|
" applies to the root logger. E.g. 'error,noisicaa.ui=info' will print" |
|
" INFO level logs for 'noisicaa.ui' and ERROR level for all other logger.")) |
|
parser.add_argument( |
|
'--data-dir', |
|
metavar='PATH', |
|
default=defaultDataDir, |
|
help="Path to noisicaa's data files (default: {})".format(config.DATA_DIR)) |
|
parser.add_argument( |
|
'--lib-dir', |
|
metavar='PATH', |
|
default=defaultLibDir, |
|
help="Path to noisicaa's libraries (default: {})".format(config.LIB_DIR)) |
|
if 'APPDIR' in os.environ: |
|
parser.add_argument( |
|
'--shell', |
|
action='store_true', |
|
help=argparse.SUPPRESS, |
|
default=False) |
|
parser.add_argument( |
|
'path', |
|
metavar="PROJECT", |
|
nargs='?', |
|
help="Project file to open.") |
|
# self.runtime_settings.init_argparser(parser) |
|
args = parser.parse_args(args=argv[1:]) |
|
# self.runtime_settings.set_from_args(args) |
|
self.__path = args.path |
|
self.__logLevel = args.log_level |
|
self.__dataDir = args.data_dir |
|
if not self.__dataDir.endswith('/'): |
|
self.__dataDir += '/' |
|
self.__libDir = args.lib_dir |
|
if not self.__libDir.endswith('/'): |
|
self.__libDir += '/' |
|
|
|
if getattr(args, 'shell', False): |
|
os.chdir(os.environ['APPDIR']) |
|
os.execlp('bash', 'bash') |
|
|
|
def __qmlEngineWarnings(self, warnings: List[QtQml.QQmlError]) -> None: |
|
qmlLogger = logging.getLogger('noisicaa.ui.qml') |
|
for warning in warnings: |
|
qmlLogger.warning(warning.toString()) |
|
|
|
def __handle_signal(self, sig: signal.Signals) -> None: |
|
logger.info("%s received.", sig.name) |
|
self.quit(1) |
|
|
|
def __handleEngineNotification(self, notification: engine_lib.EngineNotification) -> None: |
|
if notification.msg_type == engine_lib.EngineNotificationPayload.engine_state_change: |
|
engine_state_change = engine_lib.EngineStateChange(notification.msg) |
|
state = engine_lib.EngineState(engine_state_change.state) |
|
logger.info("Engine state changed to %s", state.name) |
|
|
|
elif notification.msg_type == engine_lib.EngineNotificationPayload.block_stats: |
|
block_stats = engine_lib.BlockStats(notification.msg) |
|
logger.debug("Load: %.3f%%", 100.0 * block_stats.load) |
|
|
|
elif notification.msg_type == engine_lib.EngineNotificationPayload.device_added: |
|
client = engine_lib.DeviceAdded(notification.msg) |
|
|
|
device = Device(client.name) |
|
for port in client.ports: |
|
device.addPort( |
|
port.name, |
|
engine_lib.PortDirection(port.direction), |
|
engine_lib.PortType(port.type)) |
|
|
|
self._deviceDB.addDevice(device) |
|
|
|
else: |
|
logger.error("Unhandled notification %s", notification.msg_type) |
|
|
|
def __displayMultisamplingChanged(self, v: bool) -> None: |
|
self.__settings.setValue('display/multisampling', 'true' if v else 'false') |
|
self.needsRestart = True |
|
|
|
def __displayStyleChanged(self, v: str) -> None: |
|
self.__settings.setValue('display/style', v) |
|
self.needsRestart = True |
|
|
|
def __displayMaterialThemeChanged(self, v: str) -> None: |
|
self.__settings.setValue('display/materialTheme', v) |
|
|
|
def __displayMaterialVariantChanged(self, v: str) -> None: |
|
self.__settings.setValue('display/materialVariant', v) |
|
self.needsRestart = True |
|
|
|
def __startRenderPerformanceMonitor(self) -> None: |
|
if not self.enableRenderPerformanceMonitoring: |
|
return |
|
|
|
self.__renderLoad = [] |
|
self.__renderSampleStart = None |
|
self.__renderBusyTime = 0 |
|
self.__renderStartTime = None |
|
self._window.beforeSynchronizing.connect(self.__frameStarted, Qt.DirectConnection) |
|
self._window.afterRendering.connect(self.__frameFinished, Qt.DirectConnection) |
|
|
|
def __frameStarted(self) -> None: |
|
self.__renderStartTime = time.perf_counter_ns() |
|
|
|
def __frameFinished(self) -> None: |
|
if self.__renderStartTime is not None: |
|
self.__renderBusyTime += time.perf_counter_ns() - self.__renderStartTime |
|
self.__renderStartTime = None |
|
|
|
now = time.time() |
|
if self.__renderSampleStart is None: |
|
self.__renderSampleStart = now |
|
elif now >= self.__renderSampleStart + 0.25: |
|
self.__renderLoad.append( |
|
100 * self.__renderBusyTime / (1e9 * (now - self.__renderSampleStart))) |
|
self.__renderBusyTime = 0 |
|
while len(self.__renderLoad) > 1000: |
|
self.__renderLoad.pop(0) |
|
print('{:2f}%'.format(self.__renderLoad[-1])) |
|
self.__renderSampleStart = now |
|
|
|
def quit(self, exit_code: int = 0) -> None: |
|
self.__exit_code = exit_code |
|
self.__quit.set() |
|
|
|
|
|
if __name__ == '__main__': |
|
app = MainApp() |
|
rc = app.run(sys.argv) |
|
sys.exit(rc)
|
|
|