diff --git a/friture/analyzer.py b/friture/analyzer.py index 42584b63..75cb9906 100755 --- a/friture/analyzer.py +++ b/friture/analyzer.py @@ -1,527 +1,542 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright (C) 2009 Timothée Lecomte - -# This file is part of Friture. -# -# Friture is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as published by -# the Free Software Foundation. -# -# Friture 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 Friture. If not, see . - -import sys -import os -import os.path -import argparse -import errno -import platform -import logging -import logging.handlers - -from PyQt5 import QtCore, QtWidgets -# specifically import from PyQt5.QtGui and QWidgets for startup time improvement : -from PyQt5.QtWidgets import QMainWindow, QHBoxLayout, QVBoxLayout, QApplication, QSplashScreen -from PyQt5.QtGui import QPixmap -from PyQt5.QtQml import QQmlEngine, qmlRegisterSingletonType, qmlRegisterType -from PyQt5.QtQuickWidgets import QQuickWidget -from PyQt5.QtCore import QObject - -import platformdirs - -# importing friture.exceptionhandler also installs a temporary exception hook -from friture.exceptionhandler import errorBox, fileexcepthook -import friture -from friture.ui_friture import Ui_MainWindow -from friture.about import About_Dialog # About dialog -from friture.settings import Settings_Dialog # Setting dialog -from friture.audiobuffer import AudioBuffer # audio ring buffer class -from friture.audiobackend import AudioBackend # audio backend class -from friture.dockmanager import DockManager -from friture.tileLayout import TileLayout -from friture.level_view_model import LevelViewModel -from friture.level_data import LevelData -from friture.levels import Levels_Widget -from friture.store import GetStore, Store -from friture.scope_data import Scope_Data -from friture.axis import Axis -from friture.colorBar import ColorBar -from friture.curve import Curve -from friture.playback.control import PlaybackControlWidget -from friture.playback.player import Player -from friture.plotCurve import PlotCurve -from friture.plotting.coordinateTransform import CoordinateTransform -from friture.plotting.scaleDivision import ScaleDivision, Tick -from friture.spectrogram_item import SpectrogramItem -from friture.spectrogram_item_data import SpectrogramImageData -from friture.spectrum_data import Spectrum_Data -from friture.plotFilledCurve import PlotFilledCurve -from friture.filled_curve import FilledCurve -from friture.qml_tools import qml_url, raise_if_error -from friture.generators.sine import Sine_Generator_Settings_View_Model -from friture.generators.white import White_Generator_Settings_View_Model -from friture.generators.pink import Pink_Generator_Settings_View_Model -from friture.generators.sweep import Sweep_Generator_Settings_View_Model -from friture.generators.burst import Burst_Generator_Settings_View_Model - -# the display timer could be made faster when the processing -# power allows it, firing down to every 10 ms -SMOOTH_DISPLAY_TIMER_PERIOD_MS = 10 - -# the slow timer is used for text refresh -# Text has to be refreshed slowly in order to be readable. -# (and text painting is costly) -SLOW_TIMER_PERIOD_MS = 1000 - - -class Friture(QMainWindow, ): - - def __init__(self): - QMainWindow.__init__(self) - - self.logger = logging.getLogger(__name__) - - # exception hook that logs to console, file, and display a message box - self.errorDialogOpened = False - sys.excepthook = self.excepthook - - store = GetStore() - - # set the store as the parent of the QML engine - # so that the store outlives the engine - # otherwise the store gets destroyed before the engine - # which refreshes the QML bindings to undefined values - # and QML errors are raised - self.qml_engine = QQmlEngine(store) - - # Register the ScaleDivision type. Its URI is 'ScaleDivision', it's v1.0 and the type - # will be called 'Person' in QML. - qmlRegisterType(ScaleDivision, 'Friture', 1, 0, 'ScaleDivision') - qmlRegisterType(CoordinateTransform, 'Friture', 1, 0, 'CoordinateTransform') - qmlRegisterType(Scope_Data, 'Friture', 1, 0, 'ScopeData') - qmlRegisterType(Spectrum_Data, 'Friture', 1, 0, 'SpectrumData') - qmlRegisterType(LevelData, 'Friture', 1, 0, 'LevelData') - qmlRegisterType(LevelViewModel, 'Friture', 1, 0, 'LevelViewModel') - qmlRegisterType(Axis, 'Friture', 1, 0, 'Axis') - qmlRegisterType(Curve, 'Friture', 1, 0, 'Curve') - qmlRegisterType(FilledCurve, 'Friture', 1, 0, 'FilledCurve') - qmlRegisterType(PlotCurve, 'Friture', 1, 0, 'PlotCurve') - qmlRegisterType(PlotFilledCurve, 'Friture', 1, 0, 'PlotFilledCurve') - qmlRegisterType(SpectrogramItem, 'Friture', 1, 0, 'SpectrogramItem') - qmlRegisterType(SpectrogramImageData, 'Friture', 1, 0, 'SpectrogramImageData') - qmlRegisterType(ColorBar, 'Friture', 1, 0, 'ColorBar') - qmlRegisterType(Tick, 'Friture', 1, 0, 'Tick') - qmlRegisterType(TileLayout, 'Friture', 1, 0, 'TileLayout') - qmlRegisterType(Burst_Generator_Settings_View_Model, 'Friture', 1, 0, 'Burst_Generator_Settings_View_Model') - qmlRegisterType(Pink_Generator_Settings_View_Model, 'Friture', 1, 0, 'Pink_Generator_Settings_View_Model') - qmlRegisterType(White_Generator_Settings_View_Model, 'Friture', 1, 0, 'White_Generator_Settings_View_Model') - qmlRegisterType(Sweep_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sweep_Generator_Settings_View_Model') - qmlRegisterType(Sine_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sine_Generator_Settings_View_Model') - - qmlRegisterSingletonType(Store, 'Friture', 1, 0, 'Store', lambda engine, script_engine: GetStore()) - - # Setup the user interface - self.ui = Ui_MainWindow() - self.ui.setupUi(self) - - # Initialize the audio data ring buffer - self.audiobuffer = AudioBuffer() - - # Initialize the audio backend - # signal containing new data from the audio callback thread, processed as numpy array - AudioBackend().new_data_available.connect(self.audiobuffer.handle_new_data) - - self.player = Player(self) - self.audiobuffer.new_data_available.connect(self.player.handle_new_data) - - # this timer is used to update widgets that just need to display as fast as they can - self.display_timer = QtCore.QTimer() - self.display_timer.setInterval(SMOOTH_DISPLAY_TIMER_PERIOD_MS) # constant timing - - # slow timer - self.slow_timer = QtCore.QTimer() - self.slow_timer.setInterval(SLOW_TIMER_PERIOD_MS) # constant timing - - self.about_dialog = About_Dialog(self, self.slow_timer) - self.settings_dialog = Settings_Dialog(self) - - self.level_widget = Levels_Widget(self, self.qml_engine) - self.level_widget.set_buffer(self.audiobuffer) - self.audiobuffer.new_data_available.connect(self.level_widget.handle_new_data) - - self.hboxLayout = QHBoxLayout(self.ui.centralwidget) - self.hboxLayout.setContentsMargins(0, 0, 0, 0) - self.hboxLayout.addWidget(self.level_widget) - - self.vboxLayout = QVBoxLayout() - self.hboxLayout.addLayout(self.vboxLayout) - - self.centralQuickWidget = QQuickWidget(self.qml_engine, self) - self.centralQuickWidget.setObjectName("centralQuickWidget") - self.centralQuickWidget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - self.centralQuickWidget.setResizeMode(QQuickWidget.SizeRootObjectToView) - self.centralQuickWidget.setSource(qml_url("CentralWidget.qml")) - self.vboxLayout.addWidget(self.centralQuickWidget) - - raise_if_error(self.centralQuickWidget) - - central_widget_root = self.centralQuickWidget.rootObject() - self.main_grid_layout = central_widget_root.findChild(QObject, "main_tile_layout") - assert self.main_grid_layout is not None, "Main grid layout not found in CentralWidget.qml" - - self.playback_widget = PlaybackControlWidget( - self, self.qml_engine, self.player) - self.playback_widget.setVisible(self.settings_dialog.show_playback) - self.vboxLayout.addWidget(self.playback_widget) - - self.dockmanager = DockManager(self, self.main_grid_layout) - - # timer ticks - self.display_timer.timeout.connect(self.dockmanager.canvasUpdate) - self.display_timer.timeout.connect(self.level_widget.canvasUpdate) - self.display_timer.timeout.connect(AudioBackend().fetchAudioData) - - # toolbar clicks - self.ui.actionStart.triggered.connect(self.timer_toggle) - self.ui.actionSettings.triggered.connect(self.settings_called) - self.ui.actionAbout.triggered.connect(self.about_called) - self.ui.actionNew_dock.triggered.connect(self.dockmanager.new_dock) - self.playback_widget.recording_toggled.connect(self.timer_toggle) - - # settings changes - self.settings_dialog.show_playback_changed.connect(self.show_playback_changed) - self.settings_dialog.history_length_changed.connect(self.player.set_history_seconds) - - # restore the settings and widgets geometries - self.restoreAppState() - - # make sure the toolbar is shown - # in case it was closed by mistake (before it was made impossible) - self.ui.toolBar.setVisible(True) - - # prevent from hiding or moving the toolbar - self.ui.toolBar.toggleViewAction().setVisible(False) - self.ui.toolBar.setMovable(False) - self.ui.toolBar.setFloatable(False) - - # start timers - self.timer_toggle() - self.slow_timer.start() - - self.logger.info("Init finished, entering the main loop") - - # exception hook that logs to console, file, and display a message box - def excepthook(self, exception_type, exception_value, traceback_object): - # a keyboard interrupt is an intentional exit, so close the application - if exception_type is KeyboardInterrupt: - self.close() - exit(0) - - gui_message = fileexcepthook(exception_type, exception_value, traceback_object) - - # we do not want to flood the user with message boxes when the error happens repeatedly on each timer event - if not self.errorDialogOpened: - self.errorDialogOpened = True - errorBox(gui_message) - self.errorDialogOpened = False - - # slot - def settings_called(self): - self.settings_dialog.show() - - def show_playback_changed(self, show: bool) -> None: - self.playback_widget.setVisible(show) - - # slot - def about_called(self): - self.about_dialog.show() - - # event handler - def closeEvent(self, event): - AudioBackend().close() - self.saveAppState() - event.accept() - - # method - def saveAppState(self): - settings = QtCore.QSettings("Friture", "Friture") - - settings.beginGroup("Docks") - self.dockmanager.saveState(settings) - settings.endGroup() - - settings.beginGroup("MainWindow") - windowGeometry = self.saveGeometry() - settings.setValue("windowGeometry", windowGeometry) - windowState = self.saveState() - settings.setValue("windowState", windowState) - settings.endGroup() - - settings.beginGroup("AudioBackend") - self.settings_dialog.saveState(settings) - settings.endGroup() - - # method - def migrateSettings(self): - settings = QtCore.QSettings("Friture", "Friture") - - # 1. move the central widget to a normal dock - if settings.contains("CentralWidget/type"): - settings.beginGroup("CentralWidget") - centralWidgetKeys = settings.allKeys() - children = {key: settings.value(key, type=QtCore.QVariant) for key in centralWidgetKeys} - settings.endGroup() - - if not settings.contains("Docks/central/type"): - # write them to a new dock instead - for key, value in children.items(): - settings.setValue("Docks/central/" + key, value) - - # add the new dock name to dockNames - docknames = settings.value("Docks/dockNames", []) - docknames = ["central"] + docknames - settings.setValue("Docks/dockNames", docknames) - - settings.remove("CentralWidget") - - # 2. remove any level widget - if settings.contains("Docks/dockNames"): - docknames = settings.value("Docks/dockNames", []) - if docknames == None: - docknames = [] - newDockNames = [] - for dockname in docknames: - widgetType = settings.value("Docks/" + dockname + "/type", 0, type=int) - if widgetType == 0: - settings.remove("Docks/" + dockname) - else: - newDockNames.append(dockname) - settings.setValue("Docks/dockNames", newDockNames) - - # method - def restoreAppState(self): - self.migrateSettings() - - settings = QtCore.QSettings("Friture", "Friture") - - settings.beginGroup("Docks") - self.dockmanager.restoreState(settings) - settings.endGroup() - - settings.beginGroup("MainWindow") - self.restoreGeometry(settings.value("windowGeometry", type=QtCore.QByteArray)) - self.restoreState(settings.value("windowState", type=QtCore.QByteArray)) - settings.endGroup() - - settings.beginGroup("AudioBackend") - self.settings_dialog.restoreState(settings) - settings.endGroup() - - # slot - def timer_toggle(self): - if self.display_timer.isActive(): - self.logger.info("Timer stop") - self.display_timer.stop() - self.ui.actionStart.setText("Start") - self.playback_widget.stop_recording() - AudioBackend().pause() - self.dockmanager.pause() - else: - self.logger.info("Timer start") - self.display_timer.start() - self.ui.actionStart.setText("Stop") - self.playback_widget.start_recording() - AudioBackend().restart() - self.dockmanager.restart() - - -def qt_message_handler(mode, context, message): - logger = logging.getLogger(__name__) - if mode == QtCore.QtInfoMsg: - logger.info(message) - elif mode == QtCore.QtWarningMsg: - logger.warning(message) - elif mode == QtCore.QtCriticalMsg: - logger.error(message) - elif mode == QtCore.QtFatalMsg: - logger.critical(message) - else: - logger.debug(message) - - -class StreamToLogger(object): - """ - Fake file-like stream object that redirects writes to a logger instance. - """ - - def __init__(self, logger, log_level=logging.INFO): - self.logger = logger - self.log_level = log_level - self.linebuf = '' - - def write(self, buf): - for line in buf.rstrip().splitlines(): - self.logger.log(self.log_level, line.rstrip()) - - def flush(self): - pass - - -def main(): - # parse command line arguments - parser = argparse.ArgumentParser() - - parser.add_argument( - "--python", - action="store_true", - help="Print data to friture.cprof") - - parser.add_argument( - "--kcachegrind", - action="store_true") - - parser.add_argument( - "--no-splash", - action="store_true", - help="Disable the splash screen on startup") - - program_arguments, remaining_arguments = parser.parse_known_args() - remaining_arguments.insert(0, sys.argv[0]) - - # make the Python warnings go to Friture logger - logging.captureWarnings(True) - - logFormat = "%(asctime)s %(levelname)s %(name)s: %(message)s" - formatter = logging.Formatter(logFormat) - - logFileName = "friture.log.txt" - logDir = platformdirs.user_log_dir("Friture", "") - try: - os.makedirs(logDir) - except OSError as e: - if e.errno != errno.EEXIST: - raise - logFilePath = os.path.join(logDir, logFileName) - - # log to file - fileHandler = logging.handlers.RotatingFileHandler(logFilePath, maxBytes=100000, backupCount=5) - fileHandler.setLevel(logging.DEBUG) - fileHandler.setFormatter(formatter) - - rootLogger = logging.getLogger() - rootLogger.setLevel(logging.DEBUG) - rootLogger.addHandler(fileHandler) - - if hasattr(sys, "frozen"): - # redirect stdout and stderr to the logger if this is a pyinstaller bundle - sys.stdout = StreamToLogger(logging.getLogger('STDOUT'), logging.INFO) - sys.stderr = StreamToLogger(logging.getLogger('STDERR'), logging.ERROR) - else: - # log to console if this is not a pyinstaller bundle - console = logging.StreamHandler() - console.setLevel(logging.DEBUG) - console.setFormatter(formatter) - rootLogger.addHandler(console) - - # make Qt logs go to Friture logger - QtCore.qInstallMessageHandler(qt_message_handler) - - logger = logging.getLogger(__name__) - - logger.info("Friture %s starting on %s (%s)", friture.__version__, platform.system(), sys.platform) - - logger.info("QML path: %s", qml_url("")) - - if platform.system() == "Windows": - logger.info("Applying Windows-specific setup") - - # enable automatic scaling for high-DPI screens - os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" - - # set the App ID for Windows 7 to properly display the icon in the - # taskbar. - import ctypes - myappid = 'Friture.Friture.Friture.current' # arbitrary string - try: - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) - except: - logger.error("Could not set the app model ID. If the plaftorm is older than Windows 7, this is normal.") - - app = QApplication(remaining_arguments) - - if platform.system() == "Darwin": - logger.info("Applying Mac OS-specific setup") - # help the packaged application find the Qt plugins (imageformats and platforms) - pluginsPath = os.path.normpath(os.path.join(QApplication.applicationDirPath(), os.path.pardir, 'PlugIns')) - logger.info("Adding the following to the Library paths: %s", pluginsPath) - QApplication.addLibraryPath(pluginsPath) - - if platform.system() == "Linux": - if "PIPEWIRE_ALSA" not in os.environ: - os.environ['PIPEWIRE_ALSA'] = '{ application.name = "Friture" }' - - # Set the style for Qt Quick Controls - # We choose the Fusion style as it is a desktop-oriented style - # It uses the standard system palettes to provide colors that match the desktop environment. - os.environ["QT_QUICK_CONTROLS_STYLE"] = "Fusion" - - # Splash screen - if not program_arguments.no_splash: - pixmap = QPixmap(":/images/splash.png") - splash = QSplashScreen(pixmap) - splash.show() - splash.showMessage("Initializing the audio subsystem") - app.processEvents() - - window = Friture() - window.show() - if not program_arguments.no_splash: - splash.finish(window) - - profile = "no" # "python" or "kcachegrind" or anything else to disable - - if program_arguments.python: - profile = "python" - elif program_arguments.kcachegrind: - profile = "kcachegrind" - - return_code = 0 - if profile == "python": - import cProfile - import pstats - - # friture.cprof can be visualized with SnakeViz - # http://jiffyclub.github.io/snakeviz/ - # snakeviz friture.cprof - cProfile.runctx('app.exec_()', globals(), locals(), filename="friture.cprof") - - logger.info("Profile saved to '%s'", "friture.cprof") - - stats = pstats.Stats("friture.cprof") - stats.strip_dirs().sort_stats('time').print_stats(20) - stats.strip_dirs().sort_stats('cumulative').print_stats(20) - elif profile == "kcachegrind": - import cProfile - import lsprofcalltree - - p = cProfile.Profile() - p.run('app.exec_()') - - k = lsprofcalltree.KCacheGrind(p) - with open('cachegrind.out.00000', 'wb') as data: - k.output(data) - else: - return_code = app.exec_() - - # explicitly delete the main windows instead of waiting for the interpreter shutdown - # tentative to prevent errors on exit on macos - del window - - sys.exit(return_code) +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2009 Timothée Lecomte + +# This file is part of Friture. +# +# Friture is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as published by +# the Free Software Foundation. +# +# Friture 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 Friture. If not, see . + +import sys +import os +import os.path +import argparse +import errno +import platform +import logging +import logging.handlers + +from PyQt5 import QtCore, QtWidgets +# specifically import from PyQt5.QtGui and QWidgets for startup time improvement : +from PyQt5.QtWidgets import QMainWindow, QHBoxLayout, QVBoxLayout, QApplication, QSplashScreen +from PyQt5.QtGui import QPixmap +from PyQt5.QtQml import QQmlEngine, qmlRegisterSingletonType, qmlRegisterType +from PyQt5.QtQuickWidgets import QQuickWidget +from PyQt5.QtCore import QObject + +import platformdirs + +# importing friture.exceptionhandler also installs a temporary exception hook +from friture.exceptionhandler import errorBox, fileexcepthook +import friture +from friture.ui_friture import Ui_MainWindow +from friture.about import About_Dialog # About dialog +from friture.settings import Settings_Dialog # Setting dialog +from friture.audiobuffer import AudioBuffer # audio ring buffer class +from friture.audiobackend import AudioBackend # audio backend class +from friture.dockmanager import DockManager +from friture.tilelayout import TileLayout +from friture.level_view_model import LevelViewModel +from friture.level_data import LevelData +from friture.levels import Levels_Widget +from friture.store import GetStore, Store +from friture.scope_data import Scope_Data +from friture.axis import Axis +from friture.colorBar import ColorBar +from friture.curve import Curve +from friture.playback.control import PlaybackControlWidget +from friture.playback.player import Player +from friture.plotCurve import PlotCurve +from friture.plotting.coordinateTransform import CoordinateTransform +from friture.plotting.scaleDivision import ScaleDivision, Tick +from friture.spectrogram_item import SpectrogramItem +from friture.spectrogram_item_data import SpectrogramImageData +from friture.spectrum_data import Spectrum_Data +from friture.plotFilledCurve import PlotFilledCurve +from friture.filled_curve import FilledCurve +from friture.qml_tools import qml_url, raise_if_error +from friture.theme import apply_theme +from friture.generators.sine import Sine_Generator_Settings_View_Model +from friture.generators.white import White_Generator_Settings_View_Model +from friture.generators.pink import Pink_Generator_Settings_View_Model +from friture.generators.sweep import Sweep_Generator_Settings_View_Model +from friture.generators.burst import Burst_Generator_Settings_View_Model + +# the display timer could be made faster when the processing +# power allows it, firing down to every 10 ms +SMOOTH_DISPLAY_TIMER_PERIOD_MS = 10 + +# the slow timer is used for text refresh +# Text has to be refreshed slowly in order to be readable. +# (and text painting is costly) +SLOW_TIMER_PERIOD_MS = 1000 + + +class Friture(QMainWindow, ): + + def __init__(self): + QMainWindow.__init__(self) + + self.logger = logging.getLogger(__name__) + + # exception hook that logs to console, file, and display a message box + self.errorDialogOpened = False + sys.excepthook = self.excepthook + + store = GetStore() + + # set the store as the parent of the QML engine + # so that the store outlives the engine + # otherwise the store gets destroyed before the engine + # which refreshes the QML bindings to undefined values + # and QML errors are raised + self.qml_engine = QQmlEngine(store) + + # Register the ScaleDivision type. Its URI is 'ScaleDivision', it's v1.0 and the type + # will be called 'Person' in QML. + qmlRegisterType(ScaleDivision, 'Friture', 1, 0, 'ScaleDivision') + qmlRegisterType(CoordinateTransform, 'Friture', 1, 0, 'CoordinateTransform') + qmlRegisterType(Scope_Data, 'Friture', 1, 0, 'ScopeData') + qmlRegisterType(Spectrum_Data, 'Friture', 1, 0, 'SpectrumData') + qmlRegisterType(LevelData, 'Friture', 1, 0, 'LevelData') + qmlRegisterType(LevelViewModel, 'Friture', 1, 0, 'LevelViewModel') + qmlRegisterType(Axis, 'Friture', 1, 0, 'Axis') + qmlRegisterType(Curve, 'Friture', 1, 0, 'Curve') + qmlRegisterType(FilledCurve, 'Friture', 1, 0, 'FilledCurve') + qmlRegisterType(PlotCurve, 'Friture', 1, 0, 'PlotCurve') + qmlRegisterType(PlotFilledCurve, 'Friture', 1, 0, 'PlotFilledCurve') + qmlRegisterType(SpectrogramItem, 'Friture', 1, 0, 'SpectrogramItem') + qmlRegisterType(SpectrogramImageData, 'Friture', 1, 0, 'SpectrogramImageData') + qmlRegisterType(ColorBar, 'Friture', 1, 0, 'ColorBar') + qmlRegisterType(Tick, 'Friture', 1, 0, 'Tick') + qmlRegisterType(TileLayout, 'Friture', 1, 0, 'TileLayout') + qmlRegisterType(Burst_Generator_Settings_View_Model, 'Friture', 1, 0, 'Burst_Generator_Settings_View_Model') + qmlRegisterType(Pink_Generator_Settings_View_Model, 'Friture', 1, 0, 'Pink_Generator_Settings_View_Model') + qmlRegisterType(White_Generator_Settings_View_Model, 'Friture', 1, 0, 'White_Generator_Settings_View_Model') + qmlRegisterType(Sweep_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sweep_Generator_Settings_View_Model') + qmlRegisterType(Sine_Generator_Settings_View_Model, 'Friture', 1, 0, 'Sine_Generator_Settings_View_Model') + + qmlRegisterSingletonType(Store, 'Friture', 1, 0, 'Store', lambda engine, script_engine: GetStore()) + + # Setup the user interface + self.ui = Ui_MainWindow() + self.ui.setupUi(self) + + # Initialize the audio data ring buffer + self.audiobuffer = AudioBuffer() + + # Initialize the audio backend + # signal containing new data from the audio callback thread, processed as numpy array + AudioBackend().new_data_available.connect(self.audiobuffer.handle_new_data) + + self.player = Player(self) + self.audiobuffer.new_data_available.connect(self.player.handle_new_data) + + # this timer is used to update widgets that just need to display as fast as they can + self.display_timer = QtCore.QTimer() + self.display_timer.setInterval(SMOOTH_DISPLAY_TIMER_PERIOD_MS) # constant timing + + # slow timer + self.slow_timer = QtCore.QTimer() + self.slow_timer.setInterval(SLOW_TIMER_PERIOD_MS) # constant timing + + self.about_dialog = About_Dialog(self, self.slow_timer) + self.settings_dialog = Settings_Dialog(self) + + self.level_widget = Levels_Widget(self, self.qml_engine) + self.level_widget.set_buffer(self.audiobuffer) + self.audiobuffer.new_data_available.connect(self.level_widget.handle_new_data) + + self.hboxLayout = QHBoxLayout(self.ui.centralwidget) + self.hboxLayout.setContentsMargins(0, 0, 0, 0) + self.hboxLayout.addWidget(self.level_widget) + + self.vboxLayout = QVBoxLayout() + self.hboxLayout.addLayout(self.vboxLayout) + + self.centralQuickWidget = QQuickWidget(self.qml_engine, self) + self.centralQuickWidget.setObjectName("centralQuickWidget") + self.centralQuickWidget.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + self.centralQuickWidget.setResizeMode(QQuickWidget.SizeRootObjectToView) + self.centralQuickWidget.setSource(qml_url("CentralWidget.qml")) + self.vboxLayout.addWidget(self.centralQuickWidget) + + raise_if_error(self.centralQuickWidget) + + central_widget_root = self.centralQuickWidget.rootObject() + self.main_grid_layout = central_widget_root.findChild(QObject, "main_tile_layout") + assert self.main_grid_layout is not None, "Main grid layout not found in CentralWidget.qml" + + self.playback_widget = PlaybackControlWidget( + self, self.qml_engine, self.player) + self.playback_widget.setVisible(self.settings_dialog.show_playback) + self.vboxLayout.addWidget(self.playback_widget) + + self.dockmanager = DockManager(self, self.main_grid_layout) + + # timer ticks + self.display_timer.timeout.connect(self.dockmanager.canvasUpdate) + self.display_timer.timeout.connect(self.level_widget.canvasUpdate) + self.display_timer.timeout.connect(AudioBackend().fetchAudioData) + + # toolbar clicks + self.ui.actionStart.triggered.connect(self.timer_toggle) + self.ui.actionSettings.triggered.connect(self.settings_called) + self.ui.actionAbout.triggered.connect(self.about_called) + self.ui.actionNew_dock.triggered.connect(self.dockmanager.new_dock) + self.playback_widget.recording_toggled.connect(self.timer_toggle) + + # settings changes + self.settings_dialog.show_playback_changed.connect(self.show_playback_changed) + self.settings_dialog.history_length_changed.connect(self.player.set_history_seconds) + self.settings_dialog.theme_changed.connect(self.theme_changed) + self.settings_dialog.transparency_changed.connect(self.transparency_changed) + + # restore the settings and widgets geometries + self.restoreAppState() + + # make sure the toolbar is shown + # in case it was closed by mistake (before it was made impossible) + self.ui.toolBar.setVisible(True) + + # prevent from hiding or moving the toolbar + self.ui.toolBar.toggleViewAction().setVisible(False) + self.ui.toolBar.setMovable(False) + self.ui.toolBar.setFloatable(False) + + # start timers + self.timer_toggle() + self.slow_timer.start() + + self.logger.info("Init finished, entering the main loop") + + # exception hook that logs to console, file, and display a message box + def excepthook(self, exception_type, exception_value, traceback_object): + # a keyboard interrupt is an intentional exit, so close the application + if exception_type is KeyboardInterrupt: + self.close() + exit(0) + + gui_message = fileexcepthook(exception_type, exception_value, traceback_object) + + # we do not want to flood the user with message boxes when the error happens repeatedly on each timer event + if not self.errorDialogOpened: + self.errorDialogOpened = True + errorBox(gui_message) + self.errorDialogOpened = False + + # slot + def settings_called(self): + self.settings_dialog.show() + + def show_playback_changed(self, show: bool) -> None: + self.playback_widget.setVisible(show) + + # slot + def theme_changed(self, theme_name: str) -> None: + apply_theme(theme_name) + + # slot + def transparency_changed(self, enabled: bool) -> None: + GetStore().transparency_enabled = enabled + + # slot + def about_called(self): + self.about_dialog.show() + + # event handler + def closeEvent(self, event): + AudioBackend().close() + self.saveAppState() + event.accept() + + # method + def saveAppState(self): + settings = QtCore.QSettings("Friture", "Friture") + + settings.beginGroup("Docks") + self.dockmanager.saveState(settings) + settings.endGroup() + + settings.beginGroup("MainWindow") + windowGeometry = self.saveGeometry() + settings.setValue("windowGeometry", windowGeometry) + windowState = self.saveState() + settings.setValue("windowState", windowState) + settings.endGroup() + + settings.beginGroup("AudioBackend") + self.settings_dialog.saveState(settings) + settings.endGroup() + + # method + def migrateSettings(self): + settings = QtCore.QSettings("Friture", "Friture") + + # 1. move the central widget to a normal dock + if settings.contains("CentralWidget/type"): + settings.beginGroup("CentralWidget") + centralWidgetKeys = settings.allKeys() + children = {key: settings.value(key, type=QtCore.QVariant) for key in centralWidgetKeys} + settings.endGroup() + + if not settings.contains("Docks/central/type"): + # write them to a new dock instead + for key, value in children.items(): + settings.setValue("Docks/central/" + key, value) + + # add the new dock name to dockNames + docknames = settings.value("Docks/dockNames", []) + docknames = ["central"] + docknames + settings.setValue("Docks/dockNames", docknames) + + settings.remove("CentralWidget") + + # 2. remove any level widget + if settings.contains("Docks/dockNames"): + docknames = settings.value("Docks/dockNames", []) + if docknames == None: + docknames = [] + newDockNames = [] + for dockname in docknames: + widgetType = settings.value("Docks/" + dockname + "/type", 0, type=int) + if widgetType == 0: + settings.remove("Docks/" + dockname) + else: + newDockNames.append(dockname) + settings.setValue("Docks/dockNames", newDockNames) + + # method + def restoreAppState(self): + self.migrateSettings() + + settings = QtCore.QSettings("Friture", "Friture") + + settings.beginGroup("Docks") + self.dockmanager.restoreState(settings) + settings.endGroup() + + settings.beginGroup("MainWindow") + self.restoreGeometry(settings.value("windowGeometry", type=QtCore.QByteArray)) + self.restoreState(settings.value("windowState", type=QtCore.QByteArray)) + settings.endGroup() + + settings.beginGroup("AudioBackend") + self.settings_dialog.restoreState(settings) + settings.endGroup() + + # Restore transparency setting to store + transparency_enabled = settings.value("transparency", 2, type=int) # Default to checked + GetStore().transparency_enabled = bool(transparency_enabled) + + # slot + def timer_toggle(self): + if self.display_timer.isActive(): + self.logger.info("Timer stop") + self.display_timer.stop() + self.ui.actionStart.setText("Start") + self.playback_widget.stop_recording() + AudioBackend().pause() + self.dockmanager.pause() + else: + self.logger.info("Timer start") + self.display_timer.start() + self.ui.actionStart.setText("Stop") + self.playback_widget.start_recording() + AudioBackend().restart() + self.dockmanager.restart() + + +def qt_message_handler(mode, context, message): + logger = logging.getLogger(__name__) + if mode == QtCore.QtInfoMsg: + logger.info(message) + elif mode == QtCore.QtWarningMsg: + logger.warning(message) + elif mode == QtCore.QtCriticalMsg: + logger.error(message) + elif mode == QtCore.QtFatalMsg: + logger.critical(message) + else: + logger.debug(message) + + +class StreamToLogger(object): + """ + Fake file-like stream object that redirects writes to a logger instance. + """ + + def __init__(self, logger, log_level=logging.INFO): + self.logger = logger + self.log_level = log_level + self.linebuf = '' + + def write(self, buf): + for line in buf.rstrip().splitlines(): + self.logger.log(self.log_level, line.rstrip()) + + def flush(self): + pass + + +def main(): + # parse command line arguments + parser = argparse.ArgumentParser() + + parser.add_argument( + "--python", + action="store_true", + help="Print data to friture.cprof") + + parser.add_argument( + "--kcachegrind", + action="store_true") + + parser.add_argument( + "--no-splash", + action="store_true", + help="Disable the splash screen on startup") + + program_arguments, remaining_arguments = parser.parse_known_args() + remaining_arguments.insert(0, sys.argv[0]) + + # make the Python warnings go to Friture logger + logging.captureWarnings(True) + + logFormat = "%(asctime)s %(levelname)s %(name)s: %(message)s" + formatter = logging.Formatter(logFormat) + + logFileName = "friture.log.txt" + logDir = platformdirs.user_log_dir("Friture", "") + try: + os.makedirs(logDir) + except OSError as e: + if e.errno != errno.EEXIST: + raise + logFilePath = os.path.join(logDir, logFileName) + + # log to file + fileHandler = logging.handlers.RotatingFileHandler(logFilePath, maxBytes=100000, backupCount=5) + fileHandler.setLevel(logging.DEBUG) + fileHandler.setFormatter(formatter) + + rootLogger = logging.getLogger() + rootLogger.setLevel(logging.DEBUG) + rootLogger.addHandler(fileHandler) + + if hasattr(sys, "frozen"): + # redirect stdout and stderr to the logger if this is a pyinstaller bundle + sys.stdout = StreamToLogger(logging.getLogger('STDOUT'), logging.INFO) + sys.stderr = StreamToLogger(logging.getLogger('STDERR'), logging.ERROR) + else: + # log to console if this is not a pyinstaller bundle + console = logging.StreamHandler() + console.setLevel(logging.DEBUG) + console.setFormatter(formatter) + rootLogger.addHandler(console) + + # make Qt logs go to Friture logger + QtCore.qInstallMessageHandler(qt_message_handler) + + logger = logging.getLogger(__name__) + + logger.info("Friture %s starting on %s (%s)", friture.__version__, platform.system(), sys.platform) + + logger.info("QML path: %s", qml_url("")) + + if platform.system() == "Windows": + logger.info("Applying Windows-specific setup") + + # enable automatic scaling for high-DPI screens + os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" + + # set the App ID for Windows 7 to properly display the icon in the + # taskbar. + import ctypes + myappid = 'Friture.Friture.Friture.current' # arbitrary string + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except: + logger.error("Could not set the app model ID. If the plaftorm is older than Windows 7, this is normal.") + + app = QApplication(remaining_arguments) + + if platform.system() == "Darwin": + logger.info("Applying Mac OS-specific setup") + # help the packaged application find the Qt plugins (imageformats and platforms) + pluginsPath = os.path.normpath(os.path.join(QApplication.applicationDirPath(), os.path.pardir, 'PlugIns')) + logger.info("Adding the following to the Library paths: %s", pluginsPath) + QApplication.addLibraryPath(pluginsPath) + + if platform.system() == "Linux": + if "PIPEWIRE_ALSA" not in os.environ: + os.environ['PIPEWIRE_ALSA'] = '{ application.name = "Friture" }' + + # Set the style for Qt Quick Controls + # We choose the Fusion style as it is a desktop-oriented style + # It uses the standard system palettes to provide colors that match the desktop environment. + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Fusion" + + # Splash screen + if not program_arguments.no_splash: + pixmap = QPixmap(":/images/splash.png") + splash = QSplashScreen(pixmap) + splash.show() + splash.showMessage("Initializing the audio subsystem") + app.processEvents() + + window = Friture() + window.show() + if not program_arguments.no_splash: + splash.finish(window) + + profile = "no" # "python" or "kcachegrind" or anything else to disable + + if program_arguments.python: + profile = "python" + elif program_arguments.kcachegrind: + profile = "kcachegrind" + + return_code = 0 + if profile == "python": + import cProfile + import pstats + + # friture.cprof can be visualized with SnakeViz + # http://jiffyclub.github.io/snakeviz/ + # snakeviz friture.cprof + cProfile.runctx('app.exec_()', globals(), locals(), filename="friture.cprof") + + logger.info("Profile saved to '%s'", "friture.cprof") + + stats = pstats.Stats("friture.cprof") + stats.strip_dirs().sort_stats('time').print_stats(20) + stats.strip_dirs().sort_stats('cumulative').print_stats(20) + elif profile == "kcachegrind": + import cProfile + import lsprofcalltree + + p = cProfile.Profile() + p.run('app.exec_()') + + k = lsprofcalltree.KCacheGrind(p) + with open('cachegrind.out.00000', 'wb') as data: + k.output(data) + else: + return_code = app.exec_() + + # explicitly delete the main windows instead of waiting for the interpreter shutdown + # tentative to prevent errors on exit on macos + del window + + sys.exit(return_code) diff --git a/friture/dockmanager.py b/friture/dockmanager.py index 1c8dc27e..7b0b314e 100644 --- a/friture/dockmanager.py +++ b/friture/dockmanager.py @@ -1,160 +1,160 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright (C) 2013 Timothée Lecomte - -# This file is part of Friture. -# -# Friture is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as published by -# the Free Software Foundation. -# -# Friture 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 Friture. If not, see . - -import logging - -from PyQt5 import QtCore -from PyQt5.QtWidgets import QMainWindow -from friture.defaults import DEFAULT_DOCKS -from friture.dock import Dock -from friture.tileLayout import TileLayout - -from typing import Dict, List, Optional, TYPE_CHECKING -if TYPE_CHECKING: - from friture.analyzer import Friture - - -class DockManager(QtCore.QObject): - - def __init__(self, parent: 'Friture', dock_layout: TileLayout) -> None: - super().__init__(parent) - self._parent = parent - - self.logger = logging.getLogger(__name__) - - # the parent must of the QMainWindow so that docks are created as children of it - assert isinstance(parent, QMainWindow) - - self.docks: List[Dock] = [] - self.last_settings: Dict[int, QtCore.QSettings] = {} - self.last_widget_stack: List[int] = [] - - self.dock_layout = dock_layout - - # slot - def new_dock(self) -> None: - # the dock objectName is unique - docknames = [dock.objectName() for dock in self.docks] - dockindexes = [int(str(name).partition(' ')[-1]) for name in docknames] - if len(dockindexes) == 0: - index = 1 - else: - index = max(dockindexes) + 1 - name = "Dock %d" % index - - widget_id: Optional[int] = None - settings: Optional[QtCore.QSettings] = None - if self.last_widget_stack: - widget_id = self.last_widget_stack.pop() - settings = self.last_settings.get(widget_id) - - new_dock = Dock(self._parent, name, self._parent.qml_engine, widget_id) - if settings is not None: - new_dock.restoreState(settings) - - self.docks += [new_dock] - - # slot - def close_dock(self, dock: Dock) -> None: - settings = QtCore.QSettings() - dock.saveState(settings) - assert dock.widgetId is not None # true but mypy can't prove it - self.last_settings[dock.widgetId] = settings - self.last_widget_stack.append(dock.widgetId) - - self.docks.remove(dock) - dock.cleanup() - - def movePrevious(self, dock): - i = self.docks.index(dock) - if i > 0: - self.dock_layout.movePrevious(i) - self.docks.insert(i-1, self.docks.pop(i)) - - def moveNext(self, dock): - i = self.docks.index(dock) - if i < len(self.docks) - 1: - self.dock_layout.moveNext(i) - self.docks.insert(i+1, self.docks.pop(i)) - - def saveState(self, settings): - docknames = [dock.objectName() for dock in self.docks] - settings.setValue("dockNames", docknames) - for dock in self.docks: - settings.beginGroup(dock.objectName()) - dock.saveState(settings) - settings.endGroup() - - settings.setValue("widgetIdStack", self.last_widget_stack) - settings.beginGroup("lastSettings") - for widgetId, widgetSettings in self.last_settings.items(): - settings.beginGroup(str(widgetId)) - for key in widgetSettings.allKeys(): - settings.setValue(key, widgetSettings.value(key)) - settings.endGroup() - settings.endGroup() - - def restoreState(self, settings): - if settings.contains("dockNames"): - docknames = settings.value("dockNames", []) - # list of docks - self.docks = [] - for name in docknames: - settings.beginGroup(name) - widgetId = settings.value("type", 0, type=int) - dock = Dock(self.parent(), name, self.parent().qml_engine, widgetId) - dock.restoreState(settings) - settings.endGroup() - self.docks.append(dock) - else: - self.logger.info("First launch, display a default set of docks") - self.docks = [Dock(self.parent(), "Dock %d" % (i), self.parent().qml_engine, widgetId=widget_type) for i, widget_type in enumerate(DEFAULT_DOCKS)] - - # Ugh it seems QSettings encodes an empty stack the same as None, and - # that counts as being set so it doesn't get the default, and hence the - # `or []` to correctly read back an empty stack. - self.last_widget_stack = settings.value("widgetIdStack", []) or [] - settings.beginGroup("lastSettings") - for strId in settings.childGroups(): - settings.beginGroup(strId) - widgetSettings = QtCore.QSettings() - for key in settings.allKeys(): - widgetSettings.setValue(key, settings.value(key)) - self.last_settings[int(strId)] = widgetSettings - settings.endGroup() - settings.endGroup() - - def canvasUpdate(self): - if self._parent.isVisible(): - for dock in self.docks: - dock.canvasUpdate() - - def pause(self): - for dock in self.docks: - try: - dock.pause() - except AttributeError: - pass - - def restart(self): - for dock in self.docks: - try: - dock.restart() - except AttributeError: - pass +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2013 Timothée Lecomte + +# This file is part of Friture. +# +# Friture is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as published by +# the Free Software Foundation. +# +# Friture 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 Friture. If not, see . + +import logging + +from PyQt5 import QtCore +from PyQt5.QtWidgets import QMainWindow +from friture.defaults import DEFAULT_DOCKS +from friture.dock import Dock +from friture.tilelayout import TileLayout + +from typing import Dict, List, Optional, TYPE_CHECKING +if TYPE_CHECKING: + from friture.analyzer import Friture + + +class DockManager(QtCore.QObject): + + def __init__(self, parent: 'Friture', dock_layout: TileLayout) -> None: + super().__init__(parent) + self._parent = parent + + self.logger = logging.getLogger(__name__) + + # the parent must of the QMainWindow so that docks are created as children of it + assert isinstance(parent, QMainWindow) + + self.docks: List[Dock] = [] + self.last_settings: Dict[int, QtCore.QSettings] = {} + self.last_widget_stack: List[int] = [] + + self.dock_layout = dock_layout + + # slot + def new_dock(self) -> None: + # the dock objectName is unique + docknames = [dock.objectName() for dock in self.docks] + dockindexes = [int(str(name).partition(' ')[-1]) for name in docknames] + if len(dockindexes) == 0: + index = 1 + else: + index = max(dockindexes) + 1 + name = "Dock %d" % index + + widget_id: Optional[int] = None + settings: Optional[QtCore.QSettings] = None + if self.last_widget_stack: + widget_id = self.last_widget_stack.pop() + settings = self.last_settings.get(widget_id) + + new_dock = Dock(self._parent, name, self._parent.qml_engine, widget_id) + if settings is not None: + new_dock.restoreState(settings) + + self.docks += [new_dock] + + # slot + def close_dock(self, dock: Dock) -> None: + settings = QtCore.QSettings() + dock.saveState(settings) + assert dock.widgetId is not None # true but mypy can't prove it + self.last_settings[dock.widgetId] = settings + self.last_widget_stack.append(dock.widgetId) + + self.docks.remove(dock) + dock.cleanup() + + def movePrevious(self, dock): + i = self.docks.index(dock) + if i > 0: + self.dock_layout.movePrevious(i) + self.docks.insert(i-1, self.docks.pop(i)) + + def moveNext(self, dock): + i = self.docks.index(dock) + if i < len(self.docks) - 1: + self.dock_layout.moveNext(i) + self.docks.insert(i+1, self.docks.pop(i)) + + def saveState(self, settings): + docknames = [dock.objectName() for dock in self.docks] + settings.setValue("dockNames", docknames) + for dock in self.docks: + settings.beginGroup(dock.objectName()) + dock.saveState(settings) + settings.endGroup() + + settings.setValue("widgetIdStack", self.last_widget_stack) + settings.beginGroup("lastSettings") + for widgetId, widgetSettings in self.last_settings.items(): + settings.beginGroup(str(widgetId)) + for key in widgetSettings.allKeys(): + settings.setValue(key, widgetSettings.value(key)) + settings.endGroup() + settings.endGroup() + + def restoreState(self, settings): + if settings.contains("dockNames"): + docknames = settings.value("dockNames", []) + # list of docks + self.docks = [] + for name in docknames: + settings.beginGroup(name) + widgetId = settings.value("type", 0, type=int) + dock = Dock(self.parent(), name, self.parent().qml_engine, widgetId) + dock.restoreState(settings) + settings.endGroup() + self.docks.append(dock) + else: + self.logger.info("First launch, display a default set of docks") + self.docks = [Dock(self.parent(), "Dock %d" % (i), self.parent().qml_engine, widgetId=widget_type) for i, widget_type in enumerate(DEFAULT_DOCKS)] + + # Ugh it seems QSettings encodes an empty stack the same as None, and + # that counts as being set so it doesn't get the default, and hence the + # `or []` to correctly read back an empty stack. + self.last_widget_stack = settings.value("widgetIdStack", []) or [] + settings.beginGroup("lastSettings") + for strId in settings.childGroups(): + settings.beginGroup(strId) + widgetSettings = QtCore.QSettings() + for key in settings.allKeys(): + widgetSettings.setValue(key, settings.value(key)) + self.last_settings[int(strId)] = widgetSettings + settings.endGroup() + settings.endGroup() + + def canvasUpdate(self): + if self._parent.isVisible(): + for dock in self.docks: + dock.canvasUpdate() + + def pause(self): + for dock in self.docks: + try: + dock.pause() + except AttributeError: + pass + + def restart(self): + for dock in self.docks: + try: + dock.restart() + except AttributeError: + pass diff --git a/friture/plotFilledCurve.py b/friture/plotFilledCurve.py index a6cc384c..b2ec37d4 100644 --- a/friture/plotFilledCurve.py +++ b/friture/plotFilledCurve.py @@ -1,168 +1,173 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright (C) 2021 Timothée Lecomte - -# This file is part of Friture. -# -# Friture is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as published by -# the Free Software Foundation. -# -# Friture 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 Friture. If not, see . - -import numpy as np - -from PyQt5.QtCore import pyqtSignal, pyqtProperty # type: ignore -from PyQt5.QtQuick import QQuickItem, QSGGeometryNode, QSGGeometry, QSGNode, QSGVertexColorMaterial # type: ignore - -from friture.filled_curve import CurveType, FilledCurve - -class PlotFilledCurve(QQuickItem): - curveChanged = pyqtSignal() - - def __init__(self, parent = None): - super().__init__(parent) - - self.setFlag(QQuickItem.ItemHasContents, True) - - self._curve = FilledCurve(CurveType.SIGNAL) - - @pyqtProperty(FilledCurve, notify=curveChanged) - def curve(self): - return self._curve - - @curve.setter # type: ignore - def curve(self, curve): - if curve != self._curve: - self._curve = curve - if self._curve is not None: - self._curve.data_changed.connect(self.update) - - self.update() - self.curveChanged.emit() - - def updatePaintNode(self, paint_node, update_data): - - # clip to the plot area - # find the first quad that enters the plot area - enter_indices = np.argwhere((self.curve.x_left_array() < 0.) * (self.curve.x_right_array() > 0.)) - enter_index = enter_indices[0][0] if len(enter_indices) > 0 else 0 - - # find the first quad that exits the plot area - exit_indices = np.argwhere((self.curve.x_left_array() < 1.) * (self.curve.x_right_array() > 1.)) - exit_index = exit_indices[0][0] if len(exit_indices) > 0 else -1 - - x_left = np.clip(self.curve.x_left_array()[enter_index:exit_index], 0., 1.) - x_right = np.clip(self.curve.x_right_array()[enter_index:exit_index], 0., 1.) - y = np.clip(self.curve.y_array()[enter_index:exit_index], 0., 1.) - z = np.clip(self.curve.z_array()[enter_index:exit_index], 0., 1.) - - rectangle_count = y.size - triangle_count = rectangle_count * 2 - vertex_count = triangle_count * 3 - - if rectangle_count == 0: - return - - if paint_node is None: - paint_node = QSGGeometryNode() - - geometry = QSGGeometry(QSGGeometry.defaultAttributes_ColoredPoint2D(), vertex_count) - geometry.setDrawingMode(QSGGeometry.DrawTriangles) - paint_node.setGeometry(geometry) - paint_node.setFlag(QSGNode.OwnsGeometry) - - material = QSGVertexColorMaterial() - opaque_material = QSGVertexColorMaterial() - paint_node.setMaterial(material) - paint_node.setMaterial(opaque_material) - paint_node.setFlag(QSGNode.OwnsMaterial) - paint_node.setFlag(QSGNode.OwnsOpaqueMaterial) - else: - geometry = paint_node.geometry() - geometry.allocate(vertex_count) # geometry will be marked as dirty below - - # ideally we would use geometry.vertexDataAsColoredPoint2D - # but there is a bug with the returned sip.array - # whose total size is not interpreted correctly - # `memoryview(geometry.vertexDataAsPoint2D()).nbytes` does not take itemsize into account - vertex_data = geometry.vertexData() - - # a custom structured data type that represents the vertex data is interpreted - vertex_dtype = np.dtype([('x', np.float32), ('y', np.float32), ('r', np.ubyte), ('g', np.ubyte), ('b', np.ubyte), ('a', np.ubyte)]) - vertex_data.setsize(vertex_dtype.itemsize * vertex_count) - - vertices = np.frombuffer(vertex_data, dtype=vertex_dtype) - - baseline = self.curve.baseline() * self.height() + 0.*y - h = (y - self.curve.baseline()) * self.height() - - if self.curve.curve_type == CurveType.SIGNAL: - r = 0.*z - g = (0.3 + 0.5*z) * 255 - b = 0.*z - else: - r = 255 + 0.*z - g = 255 * (1. - z) - b = 255 * (1. - z) - - a = 255 + 0.*y - - x_left_plot = np.clip(x_left, 0., 1.) * self.width() - x_right_plot = np.clip(x_right, 0., 1.) * self.width() - - # first triangle - vertices[0::6]['x'] = x_left_plot - vertices[0::6]['y'] = baseline + h - vertices[0::6]['r'] = r - vertices[0::6]['g'] = g - vertices[0::6]['b'] = b - vertices[0::6]['a'] = a - - vertices[1::6]['x'] = x_right_plot - vertices[1::6]['y'] = baseline + h - vertices[1::6]['r'] = r - vertices[1::6]['g'] = g - vertices[1::6]['b'] = b - vertices[1::6]['a'] = a - - vertices[2::6]['x'] = x_left_plot - vertices[2::6]['y'] = baseline - vertices[2::6]['r'] = r - vertices[2::6]['g'] = g - vertices[2::6]['b'] = b - vertices[2::6]['a'] = a - - # second triangle - vertices[3::6]['x'] = x_left_plot - vertices[3::6]['y'] = baseline - vertices[3::6]['r'] = r - vertices[3::6]['g'] = g - vertices[3::6]['b'] = b - vertices[3::6]['a'] = a - - vertices[4::6]['x'] = x_right_plot - vertices[4::6]['y'] = baseline - vertices[4::6]['r'] = r - vertices[4::6]['g'] = g - vertices[4::6]['b'] = b - vertices[4::6]['a'] = a - - vertices[5::6]['x'] = x_right_plot - vertices[5::6]['y'] = baseline + h - vertices[5::6]['r'] = r - vertices[5::6]['g'] = g - vertices[5::6]['b'] = b - vertices[5::6]['a'] = a - - paint_node.markDirty(QSGNode.DirtyGeometry) - paint_node.markDirty(QSGNode.DirtyMaterial) - - return paint_node +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2021 Timothée Lecomte + +# This file is part of Friture. +# +# Friture is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as published by +# the Free Software Foundation. +# +# Friture 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 Friture. If not, see . + +import numpy as np + +from PyQt5.QtCore import pyqtSignal, pyqtProperty # type: ignore +from PyQt5.QtQuick import QQuickItem, QSGGeometryNode, QSGGeometry, QSGNode, QSGVertexColorMaterial # type: ignore + +from friture.filled_curve import CurveType, FilledCurve +from friture.store import GetStore + +class PlotFilledCurve(QQuickItem): + curveChanged = pyqtSignal() + + def __init__(self, parent = None): + super().__init__(parent) + + self.setFlag(QQuickItem.ItemHasContents, True) + + self._curve = FilledCurve(CurveType.SIGNAL) + + @pyqtProperty(FilledCurve, notify=curveChanged) + def curve(self): + return self._curve + + @curve.setter # type: ignore + def curve(self, curve): + if curve != self._curve: + self._curve = curve + if self._curve is not None: + self._curve.data_changed.connect(self.update) + + self.update() + self.curveChanged.emit() + + def updatePaintNode(self, paint_node, update_data): + + # clip to the plot area + # find the first quad that enters the plot area + enter_indices = np.argwhere((self.curve.x_left_array() < 0.) * (self.curve.x_right_array() > 0.)) + enter_index = enter_indices[0][0] if len(enter_indices) > 0 else 0 + + # find the first quad that exits the plot area + exit_indices = np.argwhere((self.curve.x_left_array() < 1.) * (self.curve.x_right_array() > 1.)) + exit_index = exit_indices[0][0] if len(exit_indices) > 0 else -1 + + x_left = np.clip(self.curve.x_left_array()[enter_index:exit_index], 0., 1.) + x_right = np.clip(self.curve.x_right_array()[enter_index:exit_index], 0., 1.) + y = np.clip(self.curve.y_array()[enter_index:exit_index], 0., 1.) + z = np.clip(self.curve.z_array()[enter_index:exit_index], 0., 1.) + + rectangle_count = y.size + triangle_count = rectangle_count * 2 + vertex_count = triangle_count * 3 + + if rectangle_count == 0: + return + + if paint_node is None: + paint_node = QSGGeometryNode() + + geometry = QSGGeometry(QSGGeometry.defaultAttributes_ColoredPoint2D(), vertex_count) + geometry.setDrawingMode(QSGGeometry.DrawTriangles) + paint_node.setGeometry(geometry) + paint_node.setFlag(QSGNode.OwnsGeometry) + + material = QSGVertexColorMaterial() + opaque_material = QSGVertexColorMaterial() + paint_node.setMaterial(material) + paint_node.setMaterial(opaque_material) + paint_node.setFlag(QSGNode.OwnsMaterial) + paint_node.setFlag(QSGNode.OwnsOpaqueMaterial) + else: + geometry = paint_node.geometry() + geometry.allocate(vertex_count) # geometry will be marked as dirty below + + # ideally we would use geometry.vertexDataAsColoredPoint2D + # but there is a bug with the returned sip.array + # whose total size is not interpreted correctly + # `memoryview(geometry.vertexDataAsPoint2D()).nbytes` does not take itemsize into account + vertex_data = geometry.vertexData() + + # a custom structured data type that represents the vertex data is interpreted + vertex_dtype = np.dtype([('x', np.float32), ('y', np.float32), ('r', np.ubyte), ('g', np.ubyte), ('b', np.ubyte), ('a', np.ubyte)]) + vertex_data.setsize(vertex_dtype.itemsize * vertex_count) + + vertices = np.frombuffer(vertex_data, dtype=vertex_dtype) + + baseline = self.curve.baseline() * self.height() + 0.*y + h = (y - self.curve.baseline()) * self.height() + + if self.curve.curve_type == CurveType.SIGNAL: + r = 0.*z + g = (0.3 + 0.5*z) * 255 + b = 0.*z + else: + r = 255 + 0.*z + g = 255 * (1. - z) + b = 255 * (1. - z) + + # Add transparency to the bars - reduce alpha from 255 to about 180 for nice transparency + # Check the global transparency setting from store + store = GetStore() + alpha_value = 180 if store.transparency_enabled else 255 + a = alpha_value + 0.*y + + x_left_plot = np.clip(x_left, 0., 1.) * self.width() + x_right_plot = np.clip(x_right, 0., 1.) * self.width() + + # first triangle + vertices[0::6]['x'] = x_left_plot + vertices[0::6]['y'] = baseline + h + vertices[0::6]['r'] = r + vertices[0::6]['g'] = g + vertices[0::6]['b'] = b + vertices[0::6]['a'] = a + + vertices[1::6]['x'] = x_right_plot + vertices[1::6]['y'] = baseline + h + vertices[1::6]['r'] = r + vertices[1::6]['g'] = g + vertices[1::6]['b'] = b + vertices[1::6]['a'] = a + + vertices[2::6]['x'] = x_left_plot + vertices[2::6]['y'] = baseline + vertices[2::6]['r'] = r + vertices[2::6]['g'] = g + vertices[2::6]['b'] = b + vertices[2::6]['a'] = a + + # second triangle + vertices[3::6]['x'] = x_left_plot + vertices[3::6]['y'] = baseline + vertices[3::6]['r'] = r + vertices[3::6]['g'] = g + vertices[3::6]['b'] = b + vertices[3::6]['a'] = a + + vertices[4::6]['x'] = x_right_plot + vertices[4::6]['y'] = baseline + vertices[4::6]['r'] = r + vertices[4::6]['g'] = g + vertices[4::6]['b'] = b + vertices[4::6]['a'] = a + + vertices[5::6]['x'] = x_right_plot + vertices[5::6]['y'] = baseline + h + vertices[5::6]['r'] = r + vertices[5::6]['g'] = g + vertices[5::6]['b'] = b + vertices[5::6]['a'] = a + + paint_node.markDirty(QSGNode.DirtyGeometry) + paint_node.markDirty(QSGNode.DirtyMaterial) + + return paint_node diff --git a/friture/settings.py b/friture/settings.py index ee8e4b27..0401e5a3 100644 --- a/friture/settings.py +++ b/friture/settings.py @@ -1,209 +1,232 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright (C) 2009 Timothée Lecomte - -# This file is part of Friture. -# -# Friture is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as published by -# the Free Software Foundation. -# -# Friture 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 Friture. If not, see . - -import sys -import logging - -from PyQt5 import QtCore, QtWidgets -from PyQt5.QtCore import pyqtSignal, pyqtProperty -from friture.audiobackend import AudioBackend -from friture.ui_settings import Ui_Settings_Dialog - -no_input_device_title = "No audio input device found" - -no_input_device_message = """No audio input device has been found. - -Friture needs at least one input device. Please check your audio configuration. - -Friture will now exit. -""" - - -class Settings_Dialog(QtWidgets.QDialog, Ui_Settings_Dialog): - show_playback_changed = pyqtSignal(bool) - history_length_changed = pyqtSignal(int) - - def __init__(self, parent): - QtWidgets.QDialog.__init__(self, parent) - Ui_Settings_Dialog.__init__(self) - - self.logger = logging.getLogger(__name__) - - # Setup the user interface - self.setupUi(self) - - devices = AudioBackend().get_readable_devices_list() - - if devices == []: - # no audio input device: display a message and exit - QtWidgets.QMessageBox.critical(self, no_input_device_title, no_input_device_message) - QtCore.QTimer.singleShot(0, self.exitOnInit) - sys.exit(1) - return - - for device in devices: - self.comboBox_inputDevice.addItem(device) - - channels = AudioBackend().get_readable_current_channels() - for channel in channels: - self.comboBox_firstChannel.addItem(channel) - self.comboBox_secondChannel.addItem(channel) - - current_device = AudioBackend().get_readable_current_device() - self.comboBox_inputDevice.setCurrentIndex(current_device) - - first_channel = AudioBackend().get_current_first_channel() - self.comboBox_firstChannel.setCurrentIndex(first_channel) - second_channel = AudioBackend().get_current_second_channel() - self.comboBox_secondChannel.setCurrentIndex(second_channel) - - # signals - self.comboBox_inputDevice.currentIndexChanged.connect(self.input_device_changed) - self.comboBox_firstChannel.activated.connect(self.first_channel_changed) - self.comboBox_secondChannel.activated.connect(self.second_channel_changed) - self.radioButton_single.toggled.connect(self.single_input_type_selected) - self.radioButton_duo.toggled.connect(self.duo_input_type_selected) - self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed) - self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished) - - @pyqtProperty(bool, notify=show_playback_changed) # type: ignore - def show_playback(self) -> bool: - return bool(self.checkbox_showPlayback.checkState()) - - # slot - # used when no audio input device has been found, to exit immediately - def exitOnInit(self): - QtWidgets.QApplication.instance().quit() - - # slot - def input_device_changed(self, index): - self.parent().ui.actionStart.setChecked(False) - - success, index = AudioBackend().select_input_device(index) - - self.comboBox_inputDevice.setCurrentIndex(index) - - if not success: - # Note: the error message is a child of the settings dialog, so that - # that dialog remains on top when the error message is closed - error_message = QtWidgets.QErrorMessage(self) - error_message.setWindowTitle("Input device error") - error_message.showMessage("Impossible to use the selected input device, reverting to the previous one") - - # reset the channels - channels = AudioBackend().get_readable_current_channels() - - self.comboBox_firstChannel.clear() - self.comboBox_secondChannel.clear() - - for channel in channels: - self.comboBox_firstChannel.addItem(channel) - self.comboBox_secondChannel.addItem(channel) - - first_channel = AudioBackend().get_current_first_channel() - self.comboBox_firstChannel.setCurrentIndex(first_channel) - second_channel = AudioBackend().get_current_second_channel() - self.comboBox_secondChannel.setCurrentIndex(second_channel) - - self.parent().ui.actionStart.setChecked(True) - - # slot - def first_channel_changed(self, index): - self.parent().ui.actionStart.setChecked(False) - - success, index = AudioBackend().select_first_channel(index) - - self.comboBox_firstChannel.setCurrentIndex(index) - - if not success: - # Note: the error message is a child of the settings dialog, so that - # that dialog remains on top when the error message is closed - error_message = QtWidgets.QErrorMessage(self) - error_message.setWindowTitle("Input device error") - error_message.showMessage("Impossible to use the selected channel as the first channel, reverting to the previous one") - - self.parent().ui.actionStart.setChecked(True) - - # slot - def second_channel_changed(self, index): - self.parent().ui.actionStart.setChecked(False) - - success, index = AudioBackend().select_second_channel(index) - - self.comboBox_secondChannel.setCurrentIndex(index) - - if not success: - # Note: the error message is a child of the settings dialog, so that - # that dialog remains on top when the error message is closed - error_message = QtWidgets.QErrorMessage(self) - error_message.setWindowTitle("Input device error") - error_message.showMessage("Impossible to use the selected channel as the second channel, reverting to the previous one") - - self.parent().ui.actionStart.setChecked(True) - - # slot - def single_input_type_selected(self, checked): - if checked: - self.groupBox_second.setEnabled(False) - AudioBackend().set_single_input() - self.logger.info("Switching to single input") - - # slot - def duo_input_type_selected(self, checked): - if checked: - self.groupBox_second.setEnabled(True) - AudioBackend().set_duo_input() - self.logger.info("Switching to difference between two inputs") - - # slot - def show_playback_checkbox_changed(self, state: int) -> None: - self.show_playback_changed.emit(bool(state)) - - # slot - def history_length_edit_finished(self) -> None: - self.history_length_changed.emit(self.spinBox_historyLength.value()) - - # method - def saveState(self, settings): - # for the input device, we search by name instead of index, since - # we do not know if the device order stays the same between sessions - settings.setValue("deviceName", self.comboBox_inputDevice.currentText()) - settings.setValue("firstChannel", self.comboBox_firstChannel.currentIndex()) - settings.setValue("secondChannel", self.comboBox_secondChannel.currentIndex()) - settings.setValue("duoInput", self.inputTypeButtonGroup.checkedId()) - settings.setValue("showPlayback", self.checkbox_showPlayback.checkState()) - settings.setValue("historyLength", self.spinBox_historyLength.value()) - - # method - def restoreState(self, settings): - device_name = settings.value("deviceName", "") - device_index = self.comboBox_inputDevice.findText(device_name) - # change the device only if it exists in the device list - if device_index >= 0: - self.comboBox_inputDevice.setCurrentIndex(device_index) - channel = settings.value("firstChannel", 0, type=int) - self.comboBox_firstChannel.setCurrentIndex(channel) - channel = settings.value("secondChannel", 0, type=int) - self.comboBox_secondChannel.setCurrentIndex(channel) - duo_input_id = settings.value("duoInput", 0, type=int) - self.inputTypeButtonGroup.button(duo_input_id).setChecked(True) - self.checkbox_showPlayback.setCheckState(settings.value("showPlayback", 0, type=int)) - self.spinBox_historyLength.setValue(settings.value("historyLength", 30, type=int)) - # need to emit this because setValue doesn't emit editFinished - self.history_length_changed.emit(self.spinBox_historyLength.value()) +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2009 Timothée Lecomte + +# This file is part of Friture. +# +# Friture is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as published by +# the Free Software Foundation. +# +# Friture 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 Friture. If not, see . + +import sys +import logging + +from PyQt5 import QtCore, QtWidgets +from PyQt5.QtCore import pyqtSignal, pyqtProperty +from friture.audiobackend import AudioBackend +from friture.ui_settings import Ui_Settings_Dialog + +no_input_device_title = "No audio input device found" + +no_input_device_message = """No audio input device has been found. + +Friture needs at least one input device. Please check your audio configuration. + +Friture will now exit. +""" + + +class Settings_Dialog(QtWidgets.QDialog, Ui_Settings_Dialog): + show_playback_changed = pyqtSignal(bool) + history_length_changed = pyqtSignal(int) + theme_changed = pyqtSignal(str) + transparency_changed = pyqtSignal(bool) + + def __init__(self, parent): + QtWidgets.QDialog.__init__(self, parent) + Ui_Settings_Dialog.__init__(self) + + self.logger = logging.getLogger(__name__) + + # Setup the user interface + self.setupUi(self) + + devices = AudioBackend().get_readable_devices_list() + + if devices == []: + # no audio input device: display a message and exit + QtWidgets.QMessageBox.critical(self, no_input_device_title, no_input_device_message) + QtCore.QTimer.singleShot(0, self.exitOnInit) + sys.exit(1) + return + + for device in devices: + self.comboBox_inputDevice.addItem(device) + + channels = AudioBackend().get_readable_current_channels() + for channel in channels: + self.comboBox_firstChannel.addItem(channel) + self.comboBox_secondChannel.addItem(channel) + + current_device = AudioBackend().get_readable_current_device() + self.comboBox_inputDevice.setCurrentIndex(current_device) + + first_channel = AudioBackend().get_current_first_channel() + self.comboBox_firstChannel.setCurrentIndex(first_channel) + second_channel = AudioBackend().get_current_second_channel() + self.comboBox_secondChannel.setCurrentIndex(second_channel) + + # signals + self.comboBox_inputDevice.currentIndexChanged.connect(self.input_device_changed) + self.comboBox_firstChannel.activated.connect(self.first_channel_changed) + self.comboBox_secondChannel.activated.connect(self.second_channel_changed) + self.radioButton_single.toggled.connect(self.single_input_type_selected) + self.radioButton_duo.toggled.connect(self.duo_input_type_selected) + self.checkbox_showPlayback.stateChanged.connect(self.show_playback_checkbox_changed) + self.spinBox_historyLength.editingFinished.connect(self.history_length_edit_finished) + self.comboBox_theme.currentTextChanged.connect(self.theme_changed) + self.checkbox_transparency.stateChanged.connect(self.transparency_checkbox_changed) + + @pyqtProperty(bool, notify=show_playback_changed) # type: ignore + def show_playback(self) -> bool: + return bool(self.checkbox_showPlayback.checkState()) + + @pyqtProperty(bool) # type: ignore + def transparency_enabled(self) -> bool: + return bool(self.checkbox_transparency.checkState()) + + # slot + # used when no audio input device has been found, to exit immediately + def exitOnInit(self): + QtWidgets.QApplication.instance().quit() + + # slot + def input_device_changed(self, index): + self.parent().ui.actionStart.setChecked(False) + + success, index = AudioBackend().select_input_device(index) + + self.comboBox_inputDevice.setCurrentIndex(index) + + if not success: + # Note: the error message is a child of the settings dialog, so that + # that dialog remains on top when the error message is closed + error_message = QtWidgets.QErrorMessage(self) + error_message.setWindowTitle("Input device error") + error_message.showMessage("Impossible to use the selected input device, reverting to the previous one") + + # reset the channels + channels = AudioBackend().get_readable_current_channels() + + self.comboBox_firstChannel.clear() + self.comboBox_secondChannel.clear() + + for channel in channels: + self.comboBox_firstChannel.addItem(channel) + self.comboBox_secondChannel.addItem(channel) + + first_channel = AudioBackend().get_current_first_channel() + self.comboBox_firstChannel.setCurrentIndex(first_channel) + second_channel = AudioBackend().get_current_second_channel() + self.comboBox_secondChannel.setCurrentIndex(second_channel) + + self.parent().ui.actionStart.setChecked(True) + + # slot + def first_channel_changed(self, index): + self.parent().ui.actionStart.setChecked(False) + + success, index = AudioBackend().select_first_channel(index) + + self.comboBox_firstChannel.setCurrentIndex(index) + + if not success: + # Note: the error message is a child of the settings dialog, so that + # that dialog remains on top when the error message is closed + error_message = QtWidgets.QErrorMessage(self) + error_message.setWindowTitle("Input device error") + error_message.showMessage("Impossible to use the selected channel as the first channel, reverting to the previous one") + + self.parent().ui.actionStart.setChecked(True) + + # slot + def second_channel_changed(self, index): + self.parent().ui.actionStart.setChecked(False) + + success, index = AudioBackend().select_second_channel(index) + + self.comboBox_secondChannel.setCurrentIndex(index) + + if not success: + # Note: the error message is a child of the settings dialog, so that + # that dialog remains on top when the error message is closed + error_message = QtWidgets.QErrorMessage(self) + error_message.setWindowTitle("Input device error") + error_message.showMessage("Impossible to use the selected channel as the second channel, reverting to the previous one") + + self.parent().ui.actionStart.setChecked(True) + + # slot + def single_input_type_selected(self, checked): + if checked: + self.groupBox_second.setEnabled(False) + AudioBackend().set_single_input() + self.logger.info("Switching to single input") + + # slot + def duo_input_type_selected(self, checked): + if checked: + self.groupBox_second.setEnabled(True) + AudioBackend().set_duo_input() + self.logger.info("Switching to difference between two inputs") + + # slot + def show_playback_checkbox_changed(self, state: int) -> None: + self.show_playback_changed.emit(bool(state)) + + # slot + def history_length_edit_finished(self) -> None: + self.history_length_changed.emit(self.spinBox_historyLength.value()) + + # slot + def transparency_checkbox_changed(self, state: int) -> None: + self.transparency_changed.emit(bool(state)) + + # method + def saveState(self, settings): + # for the input device, we search by name instead of index, since + # we do not know if the device order stays the same between sessions + settings.setValue("deviceName", self.comboBox_inputDevice.currentText()) + settings.setValue("firstChannel", self.comboBox_firstChannel.currentIndex()) + settings.setValue("secondChannel", self.comboBox_secondChannel.currentIndex()) + settings.setValue("duoInput", self.inputTypeButtonGroup.checkedId()) + settings.setValue("showPlayback", self.checkbox_showPlayback.checkState()) + settings.setValue("historyLength", self.spinBox_historyLength.value()) + settings.setValue("transparency", self.checkbox_transparency.checkState()) + settings.setValue("theme", self.comboBox_theme.currentText()) + + # method + def restoreState(self, settings): + device_name = settings.value("deviceName", "") + device_index = self.comboBox_inputDevice.findText(device_name) + # change the device only if it exists in the device list + if device_index >= 0: + self.comboBox_inputDevice.setCurrentIndex(device_index) + channel = settings.value("firstChannel", 0, type=int) + self.comboBox_firstChannel.setCurrentIndex(channel) + channel = settings.value("secondChannel", 0, type=int) + self.comboBox_secondChannel.setCurrentIndex(channel) + duo_input_id = settings.value("duoInput", 0, type=int) + self.inputTypeButtonGroup.button(duo_input_id).setChecked(True) + self.checkbox_showPlayback.setCheckState(settings.value("showPlayback", 0, type=int)) + self.spinBox_historyLength.setValue(settings.value("historyLength", 30, type=int)) + # need to emit this because setValue doesn't emit editFinished + self.history_length_changed.emit(self.spinBox_historyLength.value()) + self.checkbox_transparency.setCheckState(settings.value("transparency", 2, type=int)) # Default to checked (transparent) + + # restore theme setting + theme_text = settings.value("theme", "System Default", type=str) + theme_index = self.comboBox_theme.findText(theme_text) + if theme_index >= 0: + self.comboBox_theme.setCurrentIndex(theme_index) + # emit the theme changed signal to apply the theme + self.theme_changed.emit(self.comboBox_theme.currentText()) diff --git a/friture/store.py b/friture/store.py index 3ce5058b..a803e745 100644 --- a/friture/store.py +++ b/friture/store.py @@ -1,22 +1,34 @@ -from PyQt5 import QtCore -from PyQt5.QtCore import QObject, pyqtProperty -from PyQt5.QtQml import QQmlListProperty # type: ignore - -__storeInstance = None - -def GetStore(): - global __storeInstance - if __storeInstance is None: - __storeInstance = Store() - return __storeInstance - -class Store(QtCore.QObject): - dock_states_changed = QtCore.pyqtSignal() - - def __init__(self, parent=None): - super().__init__(parent) - self._dock_states = [] - - @pyqtProperty(QQmlListProperty, notify=dock_states_changed) # type: ignore - def dock_states(self): - return QQmlListProperty(QObject, self, self._dock_states) +from PyQt5 import QtCore +from PyQt5.QtCore import QObject, pyqtProperty +from PyQt5.QtQml import QQmlListProperty # type: ignore + +__storeInstance = None + +def GetStore(): + global __storeInstance + if __storeInstance is None: + __storeInstance = Store() + return __storeInstance + +class Store(QtCore.QObject): + dock_states_changed = QtCore.pyqtSignal() + transparency_changed = QtCore.pyqtSignal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + self._dock_states = [] + self._transparency_enabled = True # Default to enabled + + @pyqtProperty(QQmlListProperty, notify=dock_states_changed) # type: ignore + def dock_states(self): + return QQmlListProperty(QObject, self, self._dock_states) + + @pyqtProperty(bool, notify=transparency_changed) # type: ignore + def transparency_enabled(self): + return self._transparency_enabled + + @transparency_enabled.setter # type: ignore + def transparency_enabled(self, enabled): + if self._transparency_enabled != enabled: + self._transparency_enabled = enabled + self.transparency_changed.emit(enabled) diff --git a/friture/theme.py b/friture/theme.py new file mode 100644 index 00000000..f30ae3d6 --- /dev/null +++ b/friture/theme.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (C) 2009 Timothée Lecomte + +# This file is part of Friture. +# +# Friture is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as published by +# the Free Software Foundation. +# +# Friture 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 Friture. If not, see . + +import logging +from PyQt5 import QtWidgets, QtGui, QtCore + + +class ThemeManager: + def __init__(self): + self.logger = logging.getLogger(__name__) + self.app = QtWidgets.QApplication.instance() + + def apply_theme(self, theme_name): + """Apply the selected theme to the application""" + self.logger.info(f"Applying theme: {theme_name}") + + if theme_name == "Dark": + self._apply_dark_theme() + elif theme_name == "Light": + self._apply_light_theme() + else: # System Default + self._apply_system_theme() + + def _apply_dark_theme(self): + """Apply dark theme""" + dark_palette = QtGui.QPalette() + + # Window colors + dark_palette.setColor(QtGui.QPalette.Window, QtGui.QColor(53, 53, 53)) + dark_palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor(255, 255, 255)) + + # Base colors (for text input fields) + dark_palette.setColor(QtGui.QPalette.Base, QtGui.QColor(25, 25, 25)) + dark_palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor(53, 53, 53)) + + # Text colors + dark_palette.setColor(QtGui.QPalette.Text, QtGui.QColor(255, 255, 255)) + + # Button colors + dark_palette.setColor(QtGui.QPalette.Button, QtGui.QColor(53, 53, 53)) + dark_palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor(255, 255, 255)) + + # Highlight colors + dark_palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor(42, 130, 218)) + dark_palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor(0, 0, 0)) + + # Disabled colors + dark_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.WindowText, QtGui.QColor(127, 127, 127)) + dark_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Text, QtGui.QColor(127, 127, 127)) + dark_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ButtonText, QtGui.QColor(127, 127, 127)) + + self.app.setPalette(dark_palette) + + # Set style sheet for better dark theme appearance + dark_style = """ + QToolTip { + color: #ffffff; + background-color: #2a2a2a; + border: 1px solid white; + } + + QGroupBox { + font-weight: bold; + border: 2px solid #555555; + border-radius: 5px; + margin-top: 1ex; + padding-top: 10px; + color: #ffffff; + } + + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + color: #ffffff; + } + + QLabel { + color: #ffffff; + } + + /* Form controls styling for dark theme */ + QComboBox { + background-color: #353535; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + padding: 4px; + } + + QComboBox:editable { + background-color: #191919; + } + + QComboBox:on { + padding-top: 3px; + padding-left: 4px; + } + + QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + border-left-width: 1px; + border-left-color: #555555; + border-left-style: solid; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + background-color: #353535; + } + + QComboBox::down-arrow { + width: 0; + height: 0; + border: 5px solid transparent; + border-top: 5px solid #ffffff; + margin-left: 2px; + } + + QComboBox QAbstractItemView { + background-color: #353535; + color: #ffffff; + border: 1px solid #555555; + selection-background-color: #2a82da; + } + + QSpinBox, QDoubleSpinBox { + background-color: #191919; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + padding: 4px; + } + + QSpinBox::up-button, QDoubleSpinBox::up-button { + subcontrol-origin: border; + subcontrol-position: top right; + width: 16px; + background-color: #353535; + border-left: 1px solid #555555; + } + + QSpinBox::down-button, QDoubleSpinBox::down-button { + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 16px; + background-color: #353535; + border-left: 1px solid #555555; + } + + QCheckBox { + color: #ffffff; + spacing: 5px; + } + + QCheckBox::indicator { + width: 18px; + height: 18px; + background-color: #191919; + border: 1px solid #555555; + border-radius: 3px; + } + + QCheckBox::indicator:checked { + background-color: #2a82da; + border-color: #2a82da; + } + + QCheckBox::indicator:checked:hover { + background-color: #3a92ea; + } + + QRadioButton { + color: #ffffff; + spacing: 5px; + } + + QRadioButton::indicator { + width: 18px; + height: 18px; + background-color: #191919; + border: 1px solid #555555; + border-radius: 9px; + } + + QRadioButton::indicator:checked { + background-color: #2a82da; + border-color: #2a82da; + } + + /* Tab widget styling */ + QTabWidget::pane { + border: 1px solid #555555; + background-color: #353535; + } + + QTabBar::tab { + background-color: #2a2a2a; + color: #ffffff; + border: 1px solid #555555; + border-bottom-color: #555555; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + min-width: 8ex; + padding: 8px; + } + + QTabBar::tab:selected { + background-color: #353535; + border-bottom-color: #353535; + } + + QTabBar::tab:hover { + background-color: #404040; + } + + /* Dialog buttons styling */ + QPushButton { + background-color: #353535; + color: #ffffff; + border: 1px solid #555555; + border-radius: 4px; + padding: 6px 12px; + min-width: 80px; + } + + QPushButton:hover { + background-color: #404040; + } + + QPushButton:pressed { + background-color: #2a2a2a; + } + """ + self.app.setStyleSheet(dark_style) + + def _apply_light_theme(self): + """Apply light theme""" + light_palette = QtGui.QPalette() + + # Window colors + light_palette.setColor(QtGui.QPalette.Window, QtGui.QColor(240, 240, 240)) + light_palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor(0, 0, 0)) + + # Base colors (for text input fields) + light_palette.setColor(QtGui.QPalette.Base, QtGui.QColor(255, 255, 255)) + light_palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor(233, 231, 227)) + + # Text colors + light_palette.setColor(QtGui.QPalette.Text, QtGui.QColor(0, 0, 0)) + + # Button colors + light_palette.setColor(QtGui.QPalette.Button, QtGui.QColor(240, 240, 240)) + light_palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor(0, 0, 0)) + + # Highlight colors + light_palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor(76, 163, 224)) + light_palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor(255, 255, 255)) + + # Disabled colors + light_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.WindowText, QtGui.QColor(120, 120, 120)) + light_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.Text, QtGui.QColor(120, 120, 120)) + light_palette.setColor(QtGui.QPalette.Disabled, QtGui.QPalette.ButtonText, QtGui.QColor(120, 120, 120)) + + self.app.setPalette(light_palette) + + # Clear any custom stylesheet + self.app.setStyleSheet("") + + def _apply_system_theme(self): + """Apply system default theme""" + # Reset to system default palette + self.app.setPalette(self.app.style().standardPalette()) + # Clear any custom stylesheet + self.app.setStyleSheet("") + + +# Global theme manager instance +_theme_manager = None + + +def get_theme_manager(): + """Get the global theme manager instance""" + global _theme_manager + if _theme_manager is None: + _theme_manager = ThemeManager() + return _theme_manager + + +def apply_theme(theme_name): + """Convenience function to apply a theme""" + get_theme_manager().apply_theme(theme_name) diff --git a/friture/ui_settings.py b/friture/ui_settings.py index 73d17032..12a17b08 100644 --- a/friture/ui_settings.py +++ b/friture/ui_settings.py @@ -1,120 +1,147 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'ui/settings.ui' -# -# Created by: PyQt5 UI code generator 5.15.10 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_Settings_Dialog(object): - def setupUi(self, Settings_Dialog): - Settings_Dialog.setObjectName("Settings_Dialog") - Settings_Dialog.resize(459, 369) - icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap(":/images-src/tools.svg"), QtGui.QIcon.Normal, QtGui.QIcon.Off) - Settings_Dialog.setWindowIcon(icon) - self.verticalLayout_5 = QtWidgets.QVBoxLayout(Settings_Dialog) - self.verticalLayout_5.setObjectName("verticalLayout_5") - self.inputGroup = QtWidgets.QGroupBox(Settings_Dialog) - self.inputGroup.setObjectName("inputGroup") - self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.inputGroup) - self.verticalLayout_6.setObjectName("verticalLayout_6") - self.label_inputType_2 = QtWidgets.QLabel(self.inputGroup) - self.label_inputType_2.setObjectName("label_inputType_2") - self.verticalLayout_6.addWidget(self.label_inputType_2) - self.comboBox_inputDevice = QtWidgets.QComboBox(self.inputGroup) - self.comboBox_inputDevice.setObjectName("comboBox_inputDevice") - self.verticalLayout_6.addWidget(self.comboBox_inputDevice) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.verticalLayout_3 = QtWidgets.QVBoxLayout() - self.verticalLayout_3.setObjectName("verticalLayout_3") - spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_3.addItem(spacerItem) - self.label_inputType = QtWidgets.QLabel(self.inputGroup) - self.label_inputType.setObjectName("label_inputType") - self.verticalLayout_3.addWidget(self.label_inputType) - self.radioButton_single = QtWidgets.QRadioButton(self.inputGroup) - self.radioButton_single.setChecked(True) - self.radioButton_single.setObjectName("radioButton_single") - self.inputTypeButtonGroup = QtWidgets.QButtonGroup(Settings_Dialog) - self.inputTypeButtonGroup.setObjectName("inputTypeButtonGroup") - self.inputTypeButtonGroup.addButton(self.radioButton_single) - self.verticalLayout_3.addWidget(self.radioButton_single) - self.radioButton_duo = QtWidgets.QRadioButton(self.inputGroup) - self.radioButton_duo.setObjectName("radioButton_duo") - self.inputTypeButtonGroup.addButton(self.radioButton_duo) - self.verticalLayout_3.addWidget(self.radioButton_duo) - spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_3.addItem(spacerItem1) - self.horizontalLayout.addLayout(self.verticalLayout_3) - self.verticalLayout_4 = QtWidgets.QVBoxLayout() - self.verticalLayout_4.setObjectName("verticalLayout_4") - self.groupBox_first = QtWidgets.QGroupBox(self.inputGroup) - self.groupBox_first.setObjectName("groupBox_first") - self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox_first) - self.verticalLayout.setObjectName("verticalLayout") - self.comboBox_firstChannel = QtWidgets.QComboBox(self.groupBox_first) - self.comboBox_firstChannel.setObjectName("comboBox_firstChannel") - self.verticalLayout.addWidget(self.comboBox_firstChannel) - self.verticalLayout_4.addWidget(self.groupBox_first) - self.groupBox_second = QtWidgets.QGroupBox(self.inputGroup) - self.groupBox_second.setEnabled(False) - self.groupBox_second.setObjectName("groupBox_second") - self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox_second) - self.verticalLayout_2.setObjectName("verticalLayout_2") - self.comboBox_secondChannel = QtWidgets.QComboBox(self.groupBox_second) - self.comboBox_secondChannel.setObjectName("comboBox_secondChannel") - self.verticalLayout_2.addWidget(self.comboBox_secondChannel) - self.verticalLayout_4.addWidget(self.groupBox_second) - self.horizontalLayout.addLayout(self.verticalLayout_4) - self.verticalLayout_6.addLayout(self.horizontalLayout) - self.verticalLayout_5.addWidget(self.inputGroup) - self.playbackGroup = QtWidgets.QGroupBox(Settings_Dialog) - self.playbackGroup.setMinimumSize(QtCore.QSize(0, 0)) - self.playbackGroup.setObjectName("playbackGroup") - self.formLayout_2 = QtWidgets.QFormLayout(self.playbackGroup) - self.formLayout_2.setObjectName("formLayout_2") - self.label_showPlayback = QtWidgets.QLabel(self.playbackGroup) - self.label_showPlayback.setObjectName("label_showPlayback") - self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_showPlayback) - self.checkbox_showPlayback = QtWidgets.QCheckBox(self.playbackGroup) - self.checkbox_showPlayback.setText("") - self.checkbox_showPlayback.setObjectName("checkbox_showPlayback") - self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.checkbox_showPlayback) - self.label_historyLength = QtWidgets.QLabel(self.playbackGroup) - self.label_historyLength.setObjectName("label_historyLength") - self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_historyLength) - self.spinBox_historyLength = QtWidgets.QSpinBox(self.playbackGroup) - self.spinBox_historyLength.setMinimum(1) - self.spinBox_historyLength.setMaximum(600) - self.spinBox_historyLength.setProperty("value", 30) - self.spinBox_historyLength.setObjectName("spinBox_historyLength") - self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.spinBox_historyLength) - self.verticalLayout_5.addWidget(self.playbackGroup) - spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) - self.verticalLayout_5.addItem(spacerItem2) - - self.retranslateUi(Settings_Dialog) - QtCore.QMetaObject.connectSlotsByName(Settings_Dialog) - - def retranslateUi(self, Settings_Dialog): - _translate = QtCore.QCoreApplication.translate - Settings_Dialog.setWindowTitle(_translate("Settings_Dialog", "Settings")) - self.inputGroup.setTitle(_translate("Settings_Dialog", "Input")) - self.label_inputType_2.setText(_translate("Settings_Dialog", "Select the input device :")) - self.label_inputType.setText(_translate("Settings_Dialog", "Select the type of input :")) - self.radioButton_single.setText(_translate("Settings_Dialog", "Single channel")) - self.radioButton_duo.setText(_translate("Settings_Dialog", "Two channels")) - self.groupBox_first.setTitle(_translate("Settings_Dialog", "First channel")) - self.groupBox_second.setTitle(_translate("Settings_Dialog", "Second channel")) - self.playbackGroup.setTitle(_translate("Settings_Dialog", "Playback")) - self.label_showPlayback.setText(_translate("Settings_Dialog", "Show Playback Controls")) - self.label_historyLength.setText(_translate("Settings_Dialog", "History Length")) - self.spinBox_historyLength.setSuffix(_translate("Settings_Dialog", " s")) -from . import friture_rc +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'ui/settings.ui' +# +# Created by: PyQt5 UI code generator 5.15.10 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_Settings_Dialog(object): + def setupUi(self, Settings_Dialog): + Settings_Dialog.setObjectName("Settings_Dialog") + Settings_Dialog.resize(459, 369) + icon = QtGui.QIcon() + icon.addPixmap(QtGui.QPixmap(":/images-src/tools.svg"), QtGui.QIcon.Normal, QtGui.QIcon.Off) + Settings_Dialog.setWindowIcon(icon) + self.verticalLayout_5 = QtWidgets.QVBoxLayout(Settings_Dialog) + self.verticalLayout_5.setObjectName("verticalLayout_5") + self.inputGroup = QtWidgets.QGroupBox(Settings_Dialog) + self.inputGroup.setObjectName("inputGroup") + self.verticalLayout_6 = QtWidgets.QVBoxLayout(self.inputGroup) + self.verticalLayout_6.setObjectName("verticalLayout_6") + self.label_inputType_2 = QtWidgets.QLabel(self.inputGroup) + self.label_inputType_2.setObjectName("label_inputType_2") + self.verticalLayout_6.addWidget(self.label_inputType_2) + self.comboBox_inputDevice = QtWidgets.QComboBox(self.inputGroup) + self.comboBox_inputDevice.setObjectName("comboBox_inputDevice") + self.verticalLayout_6.addWidget(self.comboBox_inputDevice) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.verticalLayout_3 = QtWidgets.QVBoxLayout() + self.verticalLayout_3.setObjectName("verticalLayout_3") + spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_3.addItem(spacerItem) + self.label_inputType = QtWidgets.QLabel(self.inputGroup) + self.label_inputType.setObjectName("label_inputType") + self.verticalLayout_3.addWidget(self.label_inputType) + self.radioButton_single = QtWidgets.QRadioButton(self.inputGroup) + self.radioButton_single.setChecked(True) + self.radioButton_single.setObjectName("radioButton_single") + self.inputTypeButtonGroup = QtWidgets.QButtonGroup(Settings_Dialog) + self.inputTypeButtonGroup.setObjectName("inputTypeButtonGroup") + self.inputTypeButtonGroup.addButton(self.radioButton_single) + self.verticalLayout_3.addWidget(self.radioButton_single) + self.radioButton_duo = QtWidgets.QRadioButton(self.inputGroup) + self.radioButton_duo.setObjectName("radioButton_duo") + self.inputTypeButtonGroup.addButton(self.radioButton_duo) + self.verticalLayout_3.addWidget(self.radioButton_duo) + spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_3.addItem(spacerItem1) + self.horizontalLayout.addLayout(self.verticalLayout_3) + self.verticalLayout_4 = QtWidgets.QVBoxLayout() + self.verticalLayout_4.setObjectName("verticalLayout_4") + self.groupBox_first = QtWidgets.QGroupBox(self.inputGroup) + self.groupBox_first.setObjectName("groupBox_first") + self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox_first) + self.verticalLayout.setObjectName("verticalLayout") + self.comboBox_firstChannel = QtWidgets.QComboBox(self.groupBox_first) + self.comboBox_firstChannel.setObjectName("comboBox_firstChannel") + self.verticalLayout.addWidget(self.comboBox_firstChannel) + self.verticalLayout_4.addWidget(self.groupBox_first) + self.groupBox_second = QtWidgets.QGroupBox(self.inputGroup) + self.groupBox_second.setEnabled(False) + self.groupBox_second.setObjectName("groupBox_second") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox_second) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.comboBox_secondChannel = QtWidgets.QComboBox(self.groupBox_second) + self.comboBox_secondChannel.setObjectName("comboBox_secondChannel") + self.verticalLayout_2.addWidget(self.comboBox_secondChannel) + self.verticalLayout_4.addWidget(self.groupBox_second) + self.horizontalLayout.addLayout(self.verticalLayout_4) + self.verticalLayout_6.addLayout(self.horizontalLayout) + self.verticalLayout_5.addWidget(self.inputGroup) + self.playbackGroup = QtWidgets.QGroupBox(Settings_Dialog) + self.playbackGroup.setMinimumSize(QtCore.QSize(0, 0)) + self.playbackGroup.setObjectName("playbackGroup") + self.formLayout_2 = QtWidgets.QFormLayout(self.playbackGroup) + self.formLayout_2.setObjectName("formLayout_2") + self.label_showPlayback = QtWidgets.QLabel(self.playbackGroup) + self.label_showPlayback.setObjectName("label_showPlayback") + self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_showPlayback) + self.checkbox_showPlayback = QtWidgets.QCheckBox(self.playbackGroup) + self.checkbox_showPlayback.setText("") + self.checkbox_showPlayback.setObjectName("checkbox_showPlayback") + self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.checkbox_showPlayback) + self.label_historyLength = QtWidgets.QLabel(self.playbackGroup) + self.label_historyLength.setObjectName("label_historyLength") + self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_historyLength) + self.spinBox_historyLength = QtWidgets.QSpinBox(self.playbackGroup) + self.spinBox_historyLength.setMinimum(1) + self.spinBox_historyLength.setMaximum(600) + self.spinBox_historyLength.setProperty("value", 30) + self.spinBox_historyLength.setObjectName("spinBox_historyLength") + self.formLayout_2.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.spinBox_historyLength) + self.verticalLayout_5.addWidget(self.playbackGroup) + self.themeGroup = QtWidgets.QGroupBox(Settings_Dialog) + self.themeGroup.setObjectName("themeGroup") + self.formLayout_3 = QtWidgets.QFormLayout(self.themeGroup) + self.formLayout_3.setObjectName("formLayout_3") + self.label_theme = QtWidgets.QLabel(self.themeGroup) + self.label_theme.setObjectName("label_theme") + self.formLayout_3.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.label_theme) + self.comboBox_theme = QtWidgets.QComboBox(self.themeGroup) + self.comboBox_theme.addItem("") + self.comboBox_theme.addItem("") + self.comboBox_theme.addItem("") + self.comboBox_theme.setObjectName("comboBox_theme") + self.formLayout_3.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.comboBox_theme) + self.label_transparency = QtWidgets.QLabel(self.themeGroup) + self.label_transparency.setObjectName("label_transparency") + self.formLayout_3.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.label_transparency) + self.checkbox_transparency = QtWidgets.QCheckBox(self.themeGroup) + self.checkbox_transparency.setText("") + self.checkbox_transparency.setObjectName("checkbox_transparency") + self.formLayout_3.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.checkbox_transparency) + self.verticalLayout_5.addWidget(self.themeGroup) + spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + self.verticalLayout_5.addItem(spacerItem2) + + self.retranslateUi(Settings_Dialog) + QtCore.QMetaObject.connectSlotsByName(Settings_Dialog) + + def retranslateUi(self, Settings_Dialog): + _translate = QtCore.QCoreApplication.translate + Settings_Dialog.setWindowTitle(_translate("Settings_Dialog", "Settings")) + self.inputGroup.setTitle(_translate("Settings_Dialog", "Input")) + self.label_inputType_2.setText(_translate("Settings_Dialog", "Select the input device :")) + self.label_inputType.setText(_translate("Settings_Dialog", "Select the type of input :")) + self.radioButton_single.setText(_translate("Settings_Dialog", "Single channel")) + self.radioButton_duo.setText(_translate("Settings_Dialog", "Two channels")) + self.groupBox_first.setTitle(_translate("Settings_Dialog", "First channel")) + self.groupBox_second.setTitle(_translate("Settings_Dialog", "Second channel")) + self.playbackGroup.setTitle(_translate("Settings_Dialog", "Playback")) + self.label_showPlayback.setText(_translate("Settings_Dialog", "Show Playback Controls")) + self.label_historyLength.setText(_translate("Settings_Dialog", "History Length")) + self.spinBox_historyLength.setSuffix(_translate("Settings_Dialog", " s")) + self.themeGroup.setTitle(_translate("Settings_Dialog", "Appearance")) + self.label_theme.setText(_translate("Settings_Dialog", "Theme")) + self.comboBox_theme.setItemText(0, _translate("Settings_Dialog", "System Default")) + self.comboBox_theme.setItemText(1, _translate("Settings_Dialog", "Light")) + self.comboBox_theme.setItemText(2, _translate("Settings_Dialog", "Dark")) + self.label_transparency.setText(_translate("Settings_Dialog", "Transparency")) +from . import friture_rc diff --git a/ui/settings.ui b/ui/settings.ui index a5be73d1..061d809a 100644 --- a/ui/settings.ui +++ b/ui/settings.ui @@ -1,212 +1,247 @@ - - - Settings_Dialog - - - - 0 - 0 - 459 - 369 - - - - Settings - - - - :/images-src/tools.svg:/images-src/tools.svg - - - - - - Input - - - - - - Select the input device : - - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Select the type of input : - - - - - - - Single channel - - - true - - - inputTypeButtonGroup - - - - - - - Two channels - - - inputTypeButtonGroup - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - First channel - - - - - - - - - - - - false - - - Second channel - - - - - - - - - - - - - - - - - - - - 0 - 0 - - - - Playback - - - - - - Show Playback Controls - - - - - - - - - - - - - - History Length - - - - - - - s - - - 1 - - - 600 - - - 30 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - onHistoryLengthChanged(int) - - - - - + + + Settings_Dialog + + + + 0 + 0 + 459 + 369 + + + + Settings + + + + :/images-src/tools.svg:/images-src/tools.svg + + + + + + Input + + + + + + Select the input device : + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Select the type of input : + + + + + + + Single channel + + + true + + + inputTypeButtonGroup + + + + + + + Two channels + + + inputTypeButtonGroup + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + First channel + + + + + + + + + + + + false + + + Second channel + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + Playback + + + + + + Show Playback Controls + + + + + + + + + + + + + + History Length + + + + + + + s + + + 1 + + + 600 + + + 30 + + + + + + + + + + Appearance + + + + + + Theme + + + + + + + + System Default + + + + + Light + + + + + Dark + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + onHistoryLengthChanged(int) + + + + + diff --git a/validate_theme.py b/validate_theme.py new file mode 100644 index 00000000..1d195534 --- /dev/null +++ b/validate_theme.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Validation script for theme implementation without GUI initialization +""" + +import sys +import os + +# Add the friture directory to Python path +sys.path.insert(0, '/workspaces/friture') + +def test_theme_module(): + """Test if theme module can be imported and has correct structure""" + try: + import friture.theme as theme_module + print("✓ Theme module imported successfully") + + # Check if required classes and functions exist + assert hasattr(theme_module, 'ThemeManager'), "ThemeManager class missing" + assert hasattr(theme_module, 'apply_theme'), "apply_theme function missing" + assert hasattr(theme_module, 'get_theme_manager'), "get_theme_manager function missing" + print("✓ Theme module has all required components") + + # Check ThemeManager methods + manager_methods = ['apply_theme', '_apply_dark_theme', '_apply_light_theme', '_apply_system_theme'] + for method in manager_methods: + assert hasattr(theme_module.ThemeManager, method), f"ThemeManager.{method} method missing" + print("✓ ThemeManager has all required methods") + + return True + except Exception as e: + print(f"✗ Theme module test failed: {e}") + return False + +def test_settings_changes(): + """Test if settings module has theme-related changes""" + try: + # Read the settings file to check for our additions + with open('/workspaces/friture/friture/settings.py', 'r') as f: + settings_content = f.read() + + required_strings = [ + 'theme_changed = pyqtSignal(str)', + 'self.comboBox_theme.currentTextChanged.connect(self.theme_changed)', + 'settings.setValue("theme"', + 'theme_text = settings.value("theme"' + ] + + for required in required_strings: + if required not in settings_content: + print(f"✗ Missing in settings.py: {required}") + return False + + print("✓ Settings module has all theme-related changes") + return True + except Exception as e: + print(f"✗ Settings module test failed: {e}") + return False + +def test_ui_settings_changes(): + """Test if ui_settings module has theme UI elements""" + try: + with open('/workspaces/friture/friture/ui_settings.py', 'r') as f: + ui_content = f.read() + + required_elements = [ + 'self.themeGroup = QtWidgets.QGroupBox', + 'self.label_theme = QtWidgets.QLabel', + 'self.comboBox_theme = QtWidgets.QComboBox', + 'self.themeGroup.setTitle(_translate("Settings_Dialog", "Appearance"))', + '"System Default"', '"Light"', '"Dark"' + ] + + for required in required_elements: + if required not in ui_content: + print(f"✗ Missing in ui_settings.py: {required}") + return False + + print("✓ UI Settings module has all theme UI elements") + return True + except Exception as e: + print(f"✗ UI Settings module test failed: {e}") + return False + +def test_analyzer_changes(): + """Test if analyzer module has theme integration""" + try: + with open('/workspaces/friture/friture/analyzer.py', 'r') as f: + analyzer_content = f.read() + + required_changes = [ + 'from friture.theme import apply_theme', + 'self.settings_dialog.theme_changed.connect(self.theme_changed)', + 'def theme_changed(self, theme_name: str) -> None:', + 'apply_theme(theme_name)' + ] + + for required in required_changes: + if required not in analyzer_content: + print(f"✗ Missing in analyzer.py: {required}") + return False + + print("✓ Analyzer module has all theme integration changes") + return True + except Exception as e: + print(f"✗ Analyzer module test failed: {e}") + return False + +def test_ui_file_changes(): + """Test if settings.ui has theme elements""" + try: + with open('/workspaces/friture/ui/settings.ui', 'r') as f: + ui_content = f.read() + + required_ui_elements = [ + 'name="themeGroup"', + 'Appearance', + 'name="comboBox_theme"', + 'System Default', + 'Light', + 'Dark' + ] + + for required in required_ui_elements: + if required not in ui_content: + print(f"✗ Missing in settings.ui: {required}") + return False + + print("✓ Settings UI file has all theme elements") + return True + except Exception as e: + print(f"✗ Settings UI file test failed: {e}") + return False + +def main(): + """Run all validation tests""" + print("=== Friture Theme Implementation Validation ===\n") + + tests = [ + ("Theme Module", test_theme_module), + ("Settings Module Changes", test_settings_changes), + ("UI Settings Module Changes", test_ui_settings_changes), + ("Analyzer Module Changes", test_analyzer_changes), + ("UI File Changes", test_ui_file_changes) + ] + + passed = 0 + total = len(tests) + + for test_name, test_func in tests: + print(f"Running {test_name} test...") + if test_func(): + passed += 1 + print() + else: + print() + + print(f"=== Results: {passed}/{total} tests passed ===") + + if passed == total: + print("🎉 All tests passed! Theme implementation is complete and ready to use.") + print("\nTo use the theme feature:") + print("1. Run Friture normally") + print("2. Open Settings from the toolbar") + print("3. Look for the 'Appearance' section") + print("4. Select your preferred theme from the dropdown") + print("5. The theme will apply immediately!") + else: + print(f"⚠️ {total - passed} tests failed. Please check the implementation.") + +if __name__ == '__main__': + main()