├── .gitignore ├── .pylintrc ├── AUTHORS ├── COPYING ├── README.fr.md ├── README.md ├── TODO ├── build-aux └── meson │ └── postinstall.py ├── data ├── AboutDialog.ui.in ├── app-menu.ui ├── application.css ├── com.github.mikacousin.olc.desktop.in ├── com.github.mikacousin.olc.gschema.xml ├── com.github.mikacousin.olc.metainfo.xml.in ├── help-overlay.ui ├── icons │ ├── hicolor │ │ ├── 16x16 │ │ │ └── apps │ │ │ │ └── com.github.mikacousin.olc.png │ │ ├── 22x22 │ │ │ └── apps │ │ │ │ └── com.github.mikacousin.olc.png │ │ ├── 256x256 │ │ │ └── apps │ │ │ │ └── com.github.mikacousin.olc.png │ │ ├── 32x32 │ │ │ └── apps │ │ │ │ └── com.github.mikacousin.olc.png │ │ ├── 48x48 │ │ │ └── apps │ │ │ │ └── com.github.mikacousin.olc.png │ │ └── scalable │ │ │ └── apps │ │ │ ├── com.github.mikacousin.olc-symbolic.svg │ │ │ └── com.github.mikacousin.olc.svg │ └── meson.build ├── meson.build ├── olc.gresource.xml └── settings.ui ├── meson.build ├── po ├── LINGUAS ├── POTFILES.in ├── fr.po └── meson.build ├── pytest.ini ├── src ├── application.py ├── backends │ ├── __init__.py │ ├── backend.py │ ├── ola.py │ └── sacn.py ├── channel_time.py ├── crossfade.py ├── cue.py ├── cues_edition.py ├── curve.py ├── curve_edition.py ├── define.py ├── dialog.py ├── dmx.py ├── fader.py ├── fader_bank.py ├── fader_edition.py ├── files │ ├── ascii │ │ ├── parser.py │ │ └── writer.py │ ├── export_file.py │ ├── file_type.py │ ├── import_dialog.py │ ├── import_file.py │ ├── olc │ │ ├── parser.py │ │ └── writer.py │ ├── parsed_data.py │ ├── read.py │ └── write.py ├── group.py ├── independent.py ├── independents_edition.py ├── lightshow.py ├── main_fader.py ├── meson.build ├── midi │ ├── __init__.py │ ├── control_change.py │ ├── fader.py │ ├── lcd.py │ ├── notes.py │ ├── pitchwheel.py │ ├── ports.py │ └── xfade.py ├── olc.in ├── osc.py ├── patch.py ├── patch_channels.py ├── patch_outputs.py ├── sequence.py ├── sequence_edition.py ├── settings.py ├── step.py ├── tabs_manager.py ├── timer.py ├── track_channels.py ├── virtual_console.py ├── widgets │ ├── __init__.py │ ├── button.py │ ├── channel.py │ ├── channels_view.py │ ├── common.py │ ├── controller.py │ ├── curve.py │ ├── curve_point.py │ ├── edit_curve.py │ ├── fader.py │ ├── flash.py │ ├── go.py │ ├── group.py │ ├── knob.py │ ├── main_fader.py │ ├── patch_channels.py │ ├── patch_outputs.py │ ├── pause.py │ ├── sequential.py │ ├── toggle.py │ └── track_channels.py ├── window.py ├── window_channels.py ├── window_playback.py └── zoom.py └── test ├── sample.asc ├── test_curve.py └── test_import_ascii_file.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *~ 3 | data/AboutDialog.ui 4 | data/gschemas.compiled 5 | data/olc.desktop 6 | data/olc.gresource 7 | po/*.mo 8 | po/*.gmo 9 | po/POTFILES 10 | *.valid 11 | /builddir 12 | /.flatpak-builder 13 | /flatpak 14 | /repo 15 | *.flatpak 16 | __pycache__ 17 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Mika Cousin https://github.com/mikacousin 2 | -------------------------------------------------------------------------------- /README.fr.md: -------------------------------------------------------------------------------- 1 | # Open Lighting Console 2 | 3 | [![Release](https://img.shields.io/github/v/release/mikacousin/olc?include_prereleases)](https://github.com/mikacousin/olc/releases/latest) [![License](https://img.shields.io/github/license/mikacousin/olc?color=green)](https://github.com/mikacousin/olc/blob/master/COPYING) [![Sourcery](https://img.shields.io/badge/Sourcery-enabled-brightgreen)](https://sourcery.ai) 4 | 5 | [English](README.md) 6 | 7 | Open Lighting Console (olc) est un logiciel fonctionnant sous linux pour piloter les lumières de spectacles. 8 | 9 | **version Beta** 10 | 11 | Par précaution, vous ne devriez pas utiliser de fichier ASCII-Light originaux, mais des copies. Ceci afin de ne pas perdre d'information en enregistrant dans le même fichier. 12 | 13 | Fenêtre principale : 14 | ![Screenshot](../assets/olc.png?raw=true) 15 | 16 | Console virtuelle : 17 | ![VirtualConsole](../assets/virtualconsole.png?raw=true) 18 | 19 | ## Usage 20 | 21 | - Une petite [présentation](http://mikacousin.github.io/olc/index.fr.html). 22 | - Un [manuel](http://mikacousin.github.io/olc/doc.fr/) en cours d'écriture. 23 | - Un [espace de discussion francophone](https://github.com/mikacousin/olc/discussions/categories/fran%C3%A7ais). 24 | 25 | ## Installation 26 | 27 | ### Paquets: 28 | > Recommandé pour les utilisateurs finaux. 29 | 30 | Distribution | Paquet 31 | ------------ | ------ 32 | Flatpak | [![Flathub](https://img.shields.io/flathub/v/com.github.mikacousin.olc)](https://flathub.org/apps/details/com.github.mikacousin.olc) 33 | Archlinux | [![AUR](https://img.shields.io/aur/version/olc-git)](https://aur.archlinux.org/packages/olc-git) 34 | 35 | Toute aide pour créer des paquets pour différentes distribution est bienvenue. 36 | 37 | ### Manuellement: 38 | > Si vous voulez contribuer, vous aller avoir besoin d'installer depuis les sources. 39 | 40 | #### Dependances 41 | 42 | - gtk3 >= 3.20 43 | - python3 44 | - python-gobject 45 | - gobject-introspection 46 | - ola (avec support python3) 47 | - sacn (python-sacn (AUR) pour archlinux) 48 | - mido (python-mido (AUR) pour archlinux) 49 | - pyliblo3 50 | - SciPy (python-scipy pour archlinux) 51 | - Charset Normalizer (python-charset-normalizer pour archlinux) 52 | 53 | #### Ubuntu 54 | 55 | Installez ola avec le support de python 3: 56 | ```bash 57 | $ sudo apt install ola-python 58 | ``` 59 | 60 | Installez les dépendances pour olc: 61 | ```bash 62 | $ sudo apt install meson python3-setuptools gobject-introspection cmake python-gobject libgirepository1.0-dev libgtk-3-dev python-gi-dev python3-cairo-dev python3-gi-cairo python3-liblo python3-mido python3-rtmidi gettext python3-scipy python3-charset-normalizer 63 | ``` 64 | 65 | **Il manque le paquet pour installer le module sacn pour python. Si vous connaissez une méthode pour l'installer, merci de la partager.** 66 | 67 | #### Construction à partir de git 68 | 69 | ```bash 70 | $ git clone https://github.com/mikacousin/olc.git 71 | $ cd olc 72 | $ meson setup builddir --prefix=/usr/local 73 | $ sudo ninja -C builddir install 74 | ``` 75 | 76 | Pour exécuter le logiciel sans le module sacn pour python: 77 | ```bash 78 | $ olc --backend ola 79 | ``` 80 | 81 | #### Raspberry Pi 3B+ 82 | 83 | **Plus de tests sont nécessaires** 84 | 85 | Semble fonctionner avec **1 univers et 512 circuits** (éditez le fichier src/define.py) 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Lighting Console 2 | 3 | [![Release](https://img.shields.io/github/v/release/mikacousin/olc?include_prereleases)](https://github.com/mikacousin/olc/releases/latest) [![License](https://img.shields.io/github/license/mikacousin/olc?color=green)](https://github.com/mikacousin/olc/blob/master/COPYING) [![Sourcery](https://img.shields.io/badge/Sourcery-enabled-brightgreen)](https://sourcery.ai) 4 | 5 | [French](README.fr.md) 6 | 7 | Open Lighting Console (olc) is a linux software to control lights on shows. 8 | 9 | **Beta version** 10 | 11 | As a precaution, you should not use original ascii light files, but rather copies. This, in order not to lose information by saving in the same file. 12 | 13 | Main Window : 14 | ![Screenshot](../assets/olc.png?raw=true) 15 | 16 | Virtual console : 17 | ![VirtualConsole](../assets/virtualconsole.png?raw=true) 18 | 19 | ## Usage 20 | 21 | You can find some useful informations here: [Documentation](http://mikacousin.github.io/olc/) 22 | A [manual](http://mikacousin.github.io/olc/doc.fr/) in French is being written, it will be translated when it is advanced enough. In the meantime, you can translate it with online tools. 23 | 24 | ## Installation 25 | 26 | ### Packages: 27 | > Recommended for end users 28 | 29 | Distribution | Package 30 | ------------ | ------- 31 | Flatpak | [![Flathub](https://img.shields.io/flathub/v/com.github.mikacousin.olc)](https://flathub.org/apps/details/com.github.mikacousin.olc) 32 | Archlinux | [![AUR](https://img.shields.io/aur/version/olc-git)](https://aur.archlinux.org/packages/olc-git) 33 | 34 | Any help to create packages for different distributions is welcome. 35 | 36 | ### Manually: 37 | > If you want to contribute, you'll need to install from source 38 | 39 | #### Depends on 40 | 41 | - gtk3 >= 3.20 42 | - python3 43 | - python-gobject 44 | - gobject-introspection 45 | - ola (with python3 support) 46 | - sacn (python-sacn (AUR) on archlinux) 47 | - mido (python-mido (AUR) on archlinux) 48 | - pyliblo3 49 | - SciPy (python-scipy on archlinux) 50 | - Charset Normalizer (python-charset-normalizer on archlinux) 51 | 52 | #### Ubuntu 53 | 54 | Install ola with python 3 support: 55 | ```bash 56 | $ sudo apt install ola-python 57 | ``` 58 | 59 | Install olc dependencies: 60 | ```bash 61 | $ sudo apt install meson python3-setuptools gobject-introspection cmake libgirepository1.0-dev libgtk-3-dev python-gi-dev python3-cairo-dev python3-gi-cairo python3-liblo python3-mido python3-rtmidi gettext python3-scipy python3-charset-normalizer 62 | ``` 63 | 64 | **A package for sacn python module is missing. If you know how to install it, please tell me.** 65 | 66 | #### Building from git 67 | 68 | ```bash 69 | $ git clone https://github.com/mikacousin/olc.git 70 | $ cd olc 71 | $ meson setup builddir --prefix=/usr/local 72 | $ sudo ninja -C builddir install 73 | ``` 74 | 75 | You can execute the software without sacn python module: 76 | ```bash 77 | $ olc --backend ola 78 | ``` 79 | 80 | #### Raspberry Pi 3B+ 81 | 82 | **Need some tests** 83 | 84 | Seems to work with **1 universe and 512 channels** (edit src/define.py) 85 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikacousin/olc/c8192c6aa16554fddb73ef745cb71fe587a72312/TODO -------------------------------------------------------------------------------- /build-aux/meson/postinstall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import environ, path 4 | from subprocess import call 5 | 6 | prefix = environ.get('MESON_INSTALL_PREFIX', '/usr/local') 7 | datadir = path.join(prefix, 'share') 8 | destdir = environ.get('DESTDIR', '') 9 | 10 | # Package managers set this so we don't need to run 11 | if not destdir: 12 | print('Updating icon cache...') 13 | call(['gtk-update-icon-cache', '-qtf', path.join(datadir, 'icons', 'hicolor')]) 14 | 15 | print('Updating desktop database...') 16 | call(['update-desktop-database', '-q', path.join(datadir, 'applications')]) 17 | 18 | print('Compiling GSettings schemas...') 19 | call(['glib-compile-schemas', path.join(datadir, 'glib-2.0', 'schemas')]) 20 | -------------------------------------------------------------------------------- /data/AboutDialog.ui.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | False 6 | True 7 | normal 8 | Open Lighting Console 9 | @REVISION@ 10 | Copyright © 2015-2023 Mika Cousin 11 | A Lighting Console. 12 | @PACKAGE_URL@ 13 | Visit Open Lighting Console website 14 | Mika Cousin <mika.cousin@gmail.com> 15 | com.github.mikacousin.olc 16 | gpl-3-0 17 | 18 | 19 | False 20 | 21 | 22 | False 23 | 24 | 25 | False 26 | False 27 | 0 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /data/app-menu.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | New 7 | app.new 8 | 9 | 10 | File 11 |
12 | 13 | Open 14 | app.open 15 | 16 | 17 | Save 18 | app.save 19 | 20 | 21 | Save As 22 | app.save_as 23 | 24 | 25 | Import 26 | app.import_file 27 | 28 | 29 | Export ASCII 30 | app.export_ascii 31 | 32 |
33 |
34 | 35 | Patch 36 | app.patch_outputs 37 | 38 | 39 | Curves 40 | app.curves 41 | 42 | 43 | Cues 44 | app.memories 45 | 46 | 47 | Groups 48 | app.groups 49 | 50 | 51 | Sequences 52 | app.sequences 53 | 54 | 55 | Faders 56 | app.faders 57 | 58 | 59 | Track channels 60 | app.track_channels 61 | 62 | 63 | Independents 64 | app.independents 65 | 66 | 67 | Virtual Console 68 | app.virtual_console 69 | 70 | 71 | Preferences 72 | app.settings 73 | 74 | 75 | Keyboard Shortcuts 76 | app.show-help-overlay 77 | 78 | 79 | About OLC 80 | app.about 81 | 82 | 83 | Quit 84 | app.quit 85 | 86 |
87 |
88 | -------------------------------------------------------------------------------- /data/application.css: -------------------------------------------------------------------------------- 1 | @define-color myred #880000; 2 | 3 | @binding-set unbind-keys { 4 | unbind "Right"; 5 | unbind "Left"; 6 | unbind "Up"; 7 | unbind "Down"; 8 | unbind "Tab"; 9 | unbind "ISO_Left_Tab"; 10 | unbind "space"; 11 | } 12 | 13 | flowboxchild:selected { 14 | outline-width: 0px; 15 | background-color: alpha(@theme_bg_color, 0); 16 | } 17 | 18 | window { 19 | -gtk-key-bindings: unbind-keys; 20 | } 21 | 22 | #midi_toggle:checked { 23 | background-image: none; 24 | background-color: @myred; 25 | } 26 | 27 | #flowbox_outputs { 28 | padding: 0px; 29 | } 30 | 31 | #fader_box { 32 | border-width: 2px; 33 | border-style: solid; 34 | border-color: #795004; 35 | } 36 | 37 | #fader_box_empty { 38 | border-width: 2px; 39 | border-style: solid; 40 | border-color: #666666; 41 | } 42 | -------------------------------------------------------------------------------- /data/com.github.mikacousin.olc.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Open Lighting Console 3 | Comment=Lighting Console 4 | Exec=olc 5 | Icon=com.github.mikacousin.olc 6 | Terminal=false 7 | Type=Application 8 | StartupNotify=true 9 | Categories=GNOME;GTK;Utility; 10 | -------------------------------------------------------------------------------- /data/com.github.mikacousin.olc.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | "sacn" 6 | DMX backend 7 | "sacn" or "ola" 8 | 9 | 10 | true 11 | Display in percent 12 | Display levels in percent state. 13 | 14 | 15 | 5 16 | Percent level 17 | 18 | 19 | 20 | 5 21 | Default Time 22 | 23 | 24 | 25 | 2 26 | Go Back Time 27 | 28 | 29 | 30 | true 31 | OSC 32 | Start/Stop OSC 33 | 34 | 35 | "127.0.0.1" 36 | OSC client IP address 37 | 38 | 39 | 40 | 7000 41 | OSC server port 42 | Port use by OSC server to listen 43 | 44 | 45 | 9000 46 | OSC client port 47 | Port use by OSC to send data to the client 48 | 49 | 50 | [""] 51 | MIDI Ports 52 | 53 | 54 | 55 | [""] 56 | MIDI Controllers with rotatives in Relative1 mode 57 | 58 | 59 | 60 | [""] 61 | MIDI Controllers with rotatives in Relative2 mode 62 | 63 | 64 | 65 | [""] 66 | MIDI Controllers with rotatives in Relative3 (Makie) mode 67 | 68 | 69 | 70 | [""] 71 | MIDI Controllers with rotatives in Absolute mode 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /data/com.github.mikacousin.olc.metainfo.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | com.github.mikacousin.olc.desktop 5 | CC0-1.0 6 | GPL-3.0-or-later 7 | Open Lighting Console 8 | Control your lighting shows 9 | 10 | Mika Cousin 11 | 12 | com.github.mikacousin.olc.desktop 13 | 14 |

Open Lighting Console is a free software to control lights on theater shows.

15 |

Some features:

16 | 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | https://raw.githubusercontent.com/mikacousin/olc/assets/olc.png 39 | 40 | 41 | https://raw.githubusercontent.com/mikacousin/olc/assets/virtualconsole.png 42 | 43 | 44 | olc 45 | https://mikacousin.github.io/olc 46 | 47 |
48 | -------------------------------------------------------------------------------- /data/icons/hicolor/16x16/apps/com.github.mikacousin.olc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikacousin/olc/c8192c6aa16554fddb73ef745cb71fe587a72312/data/icons/hicolor/16x16/apps/com.github.mikacousin.olc.png -------------------------------------------------------------------------------- /data/icons/hicolor/22x22/apps/com.github.mikacousin.olc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikacousin/olc/c8192c6aa16554fddb73ef745cb71fe587a72312/data/icons/hicolor/22x22/apps/com.github.mikacousin.olc.png -------------------------------------------------------------------------------- /data/icons/hicolor/256x256/apps/com.github.mikacousin.olc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikacousin/olc/c8192c6aa16554fddb73ef745cb71fe587a72312/data/icons/hicolor/256x256/apps/com.github.mikacousin.olc.png -------------------------------------------------------------------------------- /data/icons/hicolor/32x32/apps/com.github.mikacousin.olc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikacousin/olc/c8192c6aa16554fddb73ef745cb71fe587a72312/data/icons/hicolor/32x32/apps/com.github.mikacousin.olc.png -------------------------------------------------------------------------------- /data/icons/hicolor/48x48/apps/com.github.mikacousin.olc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikacousin/olc/c8192c6aa16554fddb73ef745cb71fe587a72312/data/icons/hicolor/48x48/apps/com.github.mikacousin.olc.png -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/com.github.mikacousin.olc-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 21 | 23 | image/svg+xml 24 | 26 | Gnome Symbolic Icon Theme 27 | 28 | 29 | 30 | 62 | 73 | 74 | Gnome Symbolic Icon Theme 76 | 78 | 84 | 89 | 95 | 101 | 107 | 113 | 119 | 120 | 125 | 130 | 135 | 140 | 145 | 151 | 157 | 158 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | icon_themes = ['hicolor'] 2 | foreach theme : icon_themes 3 | install_subdir(theme, install_dir: 'share/icons/') 4 | endforeach 5 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 2 | 3 | gnome = import('gnome') 4 | 5 | gnome.compile_resources('olc', 6 | 'olc.gresource.xml', 7 | gresource_bundle: true, 8 | install: true, 9 | install_dir: pkgdatadir, 10 | dependencies: configure_file( 11 | input: 'AboutDialog.ui.in', 12 | output: 'AboutDialog.ui', 13 | configuration: conf 14 | ) 15 | ) 16 | 17 | desktop_file = i18n.merge_file( 18 | input: 'com.github.mikacousin.olc.desktop.in', 19 | output: 'com.github.mikacousin.olc.desktop', 20 | type: 'desktop', 21 | po_dir: '../po', 22 | install: true, 23 | install_dir: join_paths(get_option('datadir'), 'applications') 24 | ) 25 | 26 | desktop_utils = find_program('desktop-file-validate', required: false) 27 | if desktop_utils.found() 28 | test('Validate desktop file', desktop_utils, 29 | args: [desktop_file] 30 | ) 31 | endif 32 | 33 | appstream_file = i18n.merge_file( 34 | input: 'com.github.mikacousin.olc.metainfo.xml.in', 35 | output: 'com.github.mikacousin.olc.metainfo.xml', 36 | po_dir: '../po', 37 | install: true, 38 | install_dir: join_paths(get_option('datadir'), 'metainfo') 39 | ) 40 | 41 | appstream_util = find_program('appstream-util', required: false) 42 | if appstream_util.found() 43 | test('Validate appstream file', appstream_util, 44 | args: ['validate', appstream_file] 45 | ) 46 | endif 47 | 48 | install_data('com.github.mikacousin.olc.gschema.xml', 49 | install_dir: join_paths(get_option('datadir'), 'glib-2.0/schemas') 50 | ) 51 | 52 | compile_schemas = find_program('glib-compile-schemas', required: false) 53 | if compile_schemas.found() 54 | test('Validate schema file', compile_schemas, 55 | args: ['--strict', '--dry-run', meson.current_source_dir()] 56 | ) 57 | endif 58 | 59 | subdir('icons') 60 | -------------------------------------------------------------------------------- /data/olc.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AboutDialog.ui 5 | app-menu.ui 6 | help-overlay.ui 7 | application.css 8 | settings.ui 9 | 10 | 11 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('olc', 2 | version: '0.9.2.beta', 3 | meson_version: '>= 0.50.0', 4 | ) 5 | 6 | i18n = import('i18n') 7 | py_mod = import('python') 8 | project_id = 'com.github.mikacousin.olc' 9 | 10 | py_installation = py_mod.find_installation('python3') 11 | if not py_installation.found() 12 | error('No valid python3 binary found') 13 | else 14 | message('Found python3 binary') 15 | endif 16 | 17 | dependency('gobject-introspection-1.0', version: '>= 1.35.0') 18 | dependency('gtk+-3.0', version: '>= 3.22') 19 | dependency('glib-2.0') 20 | dependency('pygobject-3.0', version: '>= 3.29.1') 21 | dependency('py3cairo') 22 | 23 | pkgdatadir = join_paths(get_option('prefix'), get_option('datadir'), meson.project_name()) 24 | revision=meson.project_version() 25 | python_dir = join_paths(get_option('prefix'), py_installation.get_install_dir()) 26 | 27 | conf = configuration_data() 28 | conf.set('PACKAGE_URL', 'https://github.com/mikacousin/olc') 29 | conf.set('REVISION', revision) 30 | conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 31 | conf.set('pkgdatadir', pkgdatadir) 32 | conf.set('pythondir', python_dir) 33 | 34 | subdir('data') 35 | subdir('src') 36 | subdir('po') 37 | 38 | meson.add_install_script('build-aux/meson/postinstall.py') 39 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | fr 2 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | # List of source files containing translatable strings. 2 | # Please keep this file sorted alphabetically. 3 | data/AboutDialog.ui.in 4 | data/app-menu.ui 5 | data/help-overlay.ui 6 | src/application.py 7 | src/settings.py 8 | -------------------------------------------------------------------------------- /po/fr.po: -------------------------------------------------------------------------------- 1 | # French translation for olc. 2 | # Copyright (C) 2016-2023 olc's COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the olc package. 4 | # Translators : 5 | # Mika Cousin , 2016-2023 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: olc master\n" 10 | "POT-Creation-Date: 2020-08-25 14:23+0200\n" 11 | "PO-Revision-Date: \n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: fr\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "X-Generator: Poedit 3.1\n" 19 | "X-Poedit-Basepath: ..\n" 20 | "X-Poedit-SearchPath-0: data/app-menu.ui\n" 21 | "X-Poedit-SearchPath-1: data/help-overlay.ui\n" 22 | "X-Poedit-SearchPath-2: data/AboutDialog.ui\n" 23 | "X-Poedit-SearchPath-3: src/application.py\n" 24 | 25 | msgid "A Lighting Console." 26 | msgstr "Une console lumière." 27 | 28 | msgid "Visit Open Lighting Console website" 29 | msgstr "Visiter le site web de Open Lighting Console" 30 | 31 | msgid "New" 32 | msgstr "Nouveau" 33 | 34 | msgid "File" 35 | msgstr "Fichier" 36 | 37 | msgid "Open" 38 | msgstr "Ouvrir" 39 | 40 | msgid "Save" 41 | msgstr "Enregistrer" 42 | 43 | msgid "Save As" 44 | msgstr "Enregistrer sous" 45 | 46 | msgid "Import" 47 | msgstr "Importer" 48 | 49 | msgid "Export ASCII" 50 | msgstr "Exporter ASCII" 51 | 52 | msgid "Export" 53 | msgstr "Exporter" 54 | 55 | msgid "Patch" 56 | msgstr "Patch" 57 | 58 | msgid "Curves" 59 | msgstr "Courbes" 60 | 61 | msgid "Cues" 62 | msgstr "Mémoires" 63 | 64 | msgid "Groups" 65 | msgstr "Groupes" 66 | 67 | msgid "Sequences" 68 | msgstr "Séquences" 69 | 70 | msgid "Masters" 71 | msgstr "Masters" 72 | 73 | msgid "Track channels" 74 | msgstr "Suivi de circuits" 75 | 76 | msgid "Virtual Console" 77 | msgstr "Console virtuelle" 78 | 79 | msgid "Preferences" 80 | msgstr "Préférences" 81 | 82 | msgid "Keyboard Shortcuts" 83 | msgstr "Raccourcis claviers" 84 | 85 | msgid "About OLC" 86 | msgstr "À propos" 87 | 88 | msgid "Quit" 89 | msgstr "Quitter" 90 | 91 | msgctxt "shortcut window" 92 | msgid "General" 93 | msgstr "Général" 94 | 95 | msgctxt "shortcut window" 96 | msgid "Fullscreen" 97 | msgstr "Plein écran" 98 | 99 | msgctxt "shortcut window" 100 | msgid "About" 101 | msgstr "À propos" 102 | 103 | msgctxt "shortcut window" 104 | msgid "Open File" 105 | msgstr "Ouvrir" 106 | 107 | msgctxt "shortcut window" 108 | msgid "Save File" 109 | msgstr "Enregistrer" 110 | 111 | msgctxt "shortcut window" 112 | msgid "Save with file name" 113 | msgstr "Enregistrer sous" 114 | 115 | msgctxt "shortcut window" 116 | msgid "Patch Outputs" 117 | msgstr "Patch outputs" 118 | 119 | msgctxt "shortcut window" 120 | msgid "Patch Channels" 121 | msgstr "Patch circuits" 122 | 123 | msgctxt "shortcut window" 124 | msgid "Masters" 125 | msgstr "Masters" 126 | 127 | msgctxt "shortcut window" 128 | msgid "Edit Sequence" 129 | msgstr "Éditer séquences" 130 | 131 | msgctxt "shortcut window" 132 | msgid "Track Channels" 133 | msgstr "Suivi de circuits" 134 | 135 | msgctxt "shortcut window" 136 | msgid "Independents" 137 | msgstr "Indépendants" 138 | 139 | msgctxt "shortcut window" 140 | msgid "Virtual Console" 141 | msgstr "Console virtuelle" 142 | 143 | msgctxt "shortcut window" 144 | msgid "Quit" 145 | msgstr "Quitter" 146 | 147 | msgctxt "shortcut window" 148 | msgid "Close Tab" 149 | msgstr "Fermer onglet" 150 | 151 | msgctxt "shortcut window" 152 | msgid "Clear" 153 | msgstr "Effacer saisie" 154 | 155 | msgctxt "shortcut window" 156 | msgid "Move" 157 | msgstr "Déplacement" 158 | 159 | msgctxt "shortcut window" 160 | msgid "Right" 161 | msgstr "Droite" 162 | 163 | msgctxt "shortcut window" 164 | msgid "Left" 165 | msgstr "Gauche" 166 | 167 | msgctxt "shortcut window" 168 | msgid "Up" 169 | msgstr "Haut" 170 | 171 | msgctxt "shortcut window" 172 | msgid "Down" 173 | msgstr "Bas" 174 | 175 | msgctxt "shortcut window" 176 | msgid "Select Channels" 177 | msgstr "Sélection des circuits" 178 | 179 | msgctxt "shortcut window" 180 | msgid "Select channel number" 181 | msgstr "Sélection du numéro de circuit" 182 | 183 | msgctxt "shortcut window" 184 | msgid "Add channel number" 185 | msgstr "Ajoute le numéro de circuit" 186 | 187 | msgctxt "shortcut window" 188 | msgid "Remove channel number" 189 | msgstr "Retire le numéro de circuit" 190 | 191 | msgctxt "shortcut window" 192 | msgid "Thru channel number" 193 | msgstr "Thru" 194 | 195 | msgctxt "shortcut window" 196 | msgid "All channels" 197 | msgstr "Tous les circuits" 198 | 199 | msgctxt "shortcut window" 200 | msgid "Levels" 201 | msgstr "Niveaux" 202 | 203 | msgctxt "shortcut window" 204 | msgid "@ level" 205 | msgstr "@ niveau" 206 | 207 | msgctxt "shortcut window" 208 | msgid "Level + %" 209 | msgstr "Niveau + %" 210 | 211 | msgctxt "shortcut window" 212 | msgid "Level - %" 213 | msgstr "Niveau - %" 214 | 215 | msgctxt "shortcut window" 216 | msgid "Time" 217 | msgstr "Temps" 218 | 219 | msgctxt "shortcut window" 220 | msgid "Time In" 221 | msgstr "Temps de monté" 222 | 223 | msgctxt "shortcut window" 224 | msgid "Time Out" 225 | msgstr "Temps de descente" 226 | 227 | msgctxt "shortcut window" 228 | msgid "Delay" 229 | msgstr "Délai" 230 | 231 | msgctxt "shortcut window" 232 | msgid "Delay In" 233 | msgstr "Délai de monté" 234 | 235 | msgctxt "shortcut window" 236 | msgid "Delay Out" 237 | msgstr "Délai de descente" 238 | 239 | msgctxt "shortcut window" 240 | msgid "Wait" 241 | msgstr "Wait" 242 | 243 | msgctxt "shortcut window" 244 | msgid "Sequence" 245 | msgstr "Séquence" 246 | 247 | msgctxt "shortcut window" 248 | msgid "Go" 249 | msgstr "Go" 250 | 251 | msgctxt "shortcut window" 252 | msgid "Next Cue" 253 | msgstr "Aller à la mémoire suivante" 254 | 255 | msgctxt "shortcut window" 256 | msgid "Prev Cue" 257 | msgstr "Aller à la mémoire précédente" 258 | 259 | msgctxt "shortcut window" 260 | msgid "Goto Cue" 261 | msgstr "Aller à la mémoire" 262 | 263 | msgctxt "shortcut window" 264 | msgid "Cue" 265 | msgstr "Mémoire" 266 | 267 | msgctxt "shortcut window" 268 | msgid "Update cue" 269 | msgstr "Mettre à jour la mémoire" 270 | 271 | msgctxt "shortcut window" 272 | msgid "Record cue" 273 | msgstr "Enregistrer mémoire" 274 | 275 | msgid "Cancel" 276 | msgstr "Annuler" 277 | 278 | msgid "MIDI Controllers" 279 | msgstr "Contrôleurs MIDI" 280 | 281 | msgid "MIDI Port" 282 | msgstr "Port MIDI" 283 | 284 | msgid "Active" 285 | msgstr "Actif" 286 | 287 | msgid "Rotary encoder Mode" 288 | msgstr "Mode des rotatifs" 289 | 290 | msgid "Save file ?" 291 | msgstr "Enregistrer le fichier ?" 292 | 293 | msgid "Don't Save" 294 | msgstr "Ne pas enregistrer" 295 | 296 | msgid "Save changes before closing ?" 297 | msgstr "Enregistrer les modifications avant la fermeture ?" 298 | 299 | msgid "Your changes will be lost if you don't save them." 300 | msgstr "Vos modifications seront perdues si vous ne les enregistrez pas." 301 | 302 | msgid "Linear" 303 | msgstr "Linéaire" 304 | 305 | msgid "Square root" 306 | msgstr "Racine carrée" 307 | 308 | msgid "Limit" 309 | msgstr "Limite à" 310 | 311 | msgid "Segment" 312 | msgstr "Segments" 313 | 314 | msgid "Interpolate" 315 | msgstr "Interpolation" 316 | 317 | msgid "Full at 1%" 318 | msgstr "Full à 1%" 319 | 320 | msgid "Select curve" 321 | msgstr "Sélectionner une courbe" 322 | 323 | msgid "Remove curve" 324 | msgstr "Supprimer la courbe" 325 | 326 | msgid "New Limit curve" 327 | msgstr "Nouvelle courbe Limite" 328 | 329 | msgid "New Segments curve" 330 | msgstr "Nouvelle courbe Segments" 331 | 332 | msgid "New Interpolate curve" 333 | msgstr "Nouvelle courbe Interplation" 334 | 335 | msgid "Percentage" 336 | msgstr "Pourcentage" 337 | 338 | msgid "+% and -% value" 339 | msgstr "Valeur de +% et -%" 340 | 341 | msgid "Default transfer time" 342 | msgstr "Temps de transfert par défaut" 343 | 344 | msgid "Default Go Back time" 345 | msgstr "Temps de Go Back par défaut" 346 | 347 | msgid "Appearance" 348 | msgstr "Apparance" 349 | 350 | msgid "Local IP address" 351 | msgstr "Adresse IP locale" 352 | 353 | msgid "Server port" 354 | msgstr "Port du serveur" 355 | 356 | msgid "Client port" 357 | msgstr "Port du client" 358 | 359 | msgid "Client IP address" 360 | msgstr "Adresse IP du client" 361 | 362 | msgid "Error" 363 | msgstr "Erreur" 364 | 365 | msgid "OLC Files" 366 | msgstr "Fichiers OLC" 367 | 368 | msgid "ASCII Files" 369 | msgstr "Fichiers ASCII" 370 | 371 | msgid "All Files" 372 | msgstr "Tous les fichiers" 373 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(meson.project_name(), preset: 'glib') 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = src /usr/local/lib/python3.11/site-packages/ 3 | -------------------------------------------------------------------------------- /src/backends/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from enum import Enum, auto 16 | 17 | from olc.dmx import Dmx 18 | from olc.patch import DMXPatch 19 | 20 | 21 | class Backend(Enum): 22 | """Available backends""" 23 | 24 | OLA = auto() 25 | SACN = auto() 26 | 27 | 28 | class DMXBackend: 29 | """Create DMX Backend""" 30 | 31 | dmx: Dmx 32 | patch: DMXPatch 33 | 34 | def __init__(self, patch): 35 | self.dmx = Dmx(self) 36 | self.patch = patch 37 | 38 | def stop(self) -> None: 39 | """Stop backend""" 40 | self.dmx.thread.stop() 41 | 42 | def send(self, universe: int, index: int) -> None: 43 | """Send DMX universe 44 | 45 | Args: 46 | universe: one in UNIVERSES 47 | index: Index of universe 48 | 49 | Raises: 50 | NotImplementedError: Must be implemented in subclass 51 | """ 52 | raise NotImplementedError 53 | -------------------------------------------------------------------------------- /src/backends/backend.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from typing import Any 16 | 17 | from gi.repository import GLib 18 | 19 | try: 20 | import sacn # noqa: F401, pylint: disable=W0611 21 | except ImportError: 22 | SACN = False 23 | else: 24 | SACN = True 25 | from .sacn import Sacn 26 | try: 27 | import ola # noqa: F401, pylint: disable=W0611 28 | except ImportError: 29 | OLA = False 30 | else: 31 | OLA = True 32 | from .ola import Ola 33 | 34 | 35 | def select_backend(options, settings, patch) -> Any: 36 | """Select and create DMX backend 37 | 38 | Args: 39 | options: command line options 40 | settings: GSettings 41 | patch: DMX patch 42 | 43 | Returns: 44 | Backend or None 45 | """ 46 | backend = settings.get_string("backend") 47 | if "backend" in options: 48 | backend = options["backend"] 49 | if "ola" in backend: 50 | if OLA: 51 | olad_port = 9090 52 | if "http-port" in options: 53 | olad_port = options["http-port"] 54 | settings.set_value("backend", GLib.Variant("s", backend)) 55 | return Ola(patch, olad_port=olad_port) 56 | print("Can't find ola python module") 57 | return None 58 | if "sacn" in backend: 59 | if SACN: 60 | settings.set_value("backend", GLib.Variant("s", backend)) 61 | return Sacn(patch) 62 | print("Can't find sACN python module") 63 | return None 64 | if backend: 65 | print(f"{backend} is not supported. Fallback to sACN") 66 | if SACN: 67 | settings.set_value("backend", GLib.Variant("s", backend)) 68 | return Sacn(patch) 69 | print("Can't find sACN python module") 70 | return None 71 | -------------------------------------------------------------------------------- /src/backends/ola.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import array 16 | import socket 17 | import subprocess 18 | import sys 19 | import threading 20 | import time 21 | from functools import partial 22 | from typing import Optional 23 | 24 | from gi.repository import GLib 25 | from ola import OlaClient 26 | from ola.ClientWrapper import ClientWrapper 27 | from olc.backends import DMXBackend 28 | from olc.define import NB_UNIVERSES, UNIVERSES, App 29 | from olc.patch import DMXPatch 30 | 31 | 32 | class OlaThread(threading.Thread): 33 | """Create OlaClient and receive universes updates""" 34 | 35 | wrapper: ClientWrapper 36 | client: OlaClient.OlaClient 37 | old_frame: list[array.array] 38 | patch: DMXPatch 39 | 40 | def __init__(self, patch: DMXPatch): 41 | super().__init__() 42 | self.wrapper = ClientWrapper() 43 | self.client = self.wrapper.Client() 44 | self.old_frame = [array.array("B", [0] * 512) for _ in range(NB_UNIVERSES)] 45 | self.patch = patch 46 | 47 | def run(self) -> None: 48 | """Register universes""" 49 | self.wrapper = ClientWrapper() 50 | self.client = self.wrapper.Client() 51 | for univ in UNIVERSES: 52 | self.client.RegisterUniverse(univ, self.client.REGISTER, 53 | partial(self.on_dmx, univ)) 54 | self.wrapper.Run() 55 | 56 | def on_dmx(self, univ: int, dmxframe: array.array) -> None: 57 | """Executed when ola detect universe update 58 | 59 | Args: 60 | univ: universe 61 | dmxframe: 512 bytes with levels outputs 62 | """ 63 | idx = UNIVERSES.index(univ) 64 | if App().tabs.tabs["patch_outputs"]: 65 | # Find diff between old and new DMX frames 66 | outputs = [ 67 | index 68 | for index, (e1, e2) in enumerate(zip(dmxframe, self.old_frame[idx])) 69 | if e1 != e2 70 | ] 71 | # Loop on outputs with different level 72 | for output in outputs: 73 | if self.patch.outputs.get(univ) and self.patch.outputs[univ].get( 74 | output + 1): 75 | GLib.idle_add( 76 | App().tabs.tabs["patch_outputs"].outputs[output + 77 | (idx * 78 | 512)].queue_draw) 79 | # Save DMX frame for next call 80 | self.old_frame[idx] = dmxframe 81 | 82 | def fetch_dmx(self, status: OlaClient.RequestStatus, univ: int, 83 | dmxframe: array.array) -> None: 84 | """Fetch DMX 85 | 86 | Args: 87 | status: RequestStatus 88 | univ: DMX universe 89 | dmxframe: DMX data 90 | """ 91 | if not status.Succeeded() or not dmxframe: 92 | return 93 | index = UNIVERSES.index(univ) 94 | self.old_frame[index] = dmxframe 95 | for output, level in enumerate(dmxframe): 96 | if univ in self.patch.outputs and output + 1 in self.patch.outputs[univ]: 97 | channel = self.patch.outputs.get(univ).get(output + 1)[0] 98 | App().backend.dmx.frame[index][output] = level 99 | next_level = App().lightshow.main_playback.get_next_channel_level( 100 | channel, level) 101 | App().window.live_view.update_channel_widget(channel, next_level) 102 | if App().tabs.tabs["patch_outputs"]: 103 | App().tabs.tabs["patch_outputs"].outputs[output + 104 | (512 * 105 | index)].queue_draw() 106 | 107 | 108 | class Ola(DMXBackend): 109 | """Ola Backend""" 110 | 111 | olad_port: int 112 | olad_pid: Optional[subprocess.Popen] 113 | thread: OlaThread | None 114 | 115 | def __init__(self, patch, olad_port: int = 9090): 116 | super().__init__(patch) 117 | self.thread = None 118 | self.olad_port = olad_port 119 | self.olad_pid = None 120 | 121 | # Create OlaClient and start olad if needed 122 | try: 123 | self.thread = OlaThread(self.patch) 124 | self.olad_pid = None 125 | except OlaClient.OLADNotRunningException: 126 | if _is_port_in_use(self.olad_port): 127 | print(f"Olad port {self.olad_port} already in use") 128 | sys.exit() 129 | # Launch olad if not running 130 | cmd = ["olad", "--http-port", str(self.olad_port)] 131 | self.olad_pid = subprocess.Popen(cmd) # pylint: disable=R1732 132 | # Wait olad starting 133 | timeout = 15 134 | timer = 0.0 135 | wrapper = None 136 | while not wrapper: 137 | try: 138 | wrapper = ClientWrapper() 139 | except (OlaClient.OLADNotRunningException, ConnectionError): 140 | time.sleep(0.1) 141 | timer += 0.1 142 | if timer >= timeout: 143 | print("Can't start olad") 144 | break 145 | self.thread = OlaThread(self.patch) 146 | self.thread.start() 147 | 148 | def stop(self) -> None: 149 | """Stop Ola backend""" 150 | super().stop() 151 | if self.thread: 152 | self.thread.wrapper.Stop() 153 | # Stop olad if we launched it 154 | if self.olad_pid: 155 | self.olad_pid.terminate() 156 | 157 | def send(self, universe: int, index: int) -> None: 158 | """Send DMX universe 159 | 160 | Args: 161 | universe: one in UNIVERSES 162 | index: Index of universe 163 | """ 164 | if self.thread: 165 | self.thread.client.SendDmx(universe, self.dmx.frame[index]) 166 | 167 | 168 | def _is_port_in_use(port: int) -> bool: 169 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as serversocket: 170 | return serversocket.connect_ex(("127.0.0.1", port)) == 0 171 | -------------------------------------------------------------------------------- /src/backends/sacn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import sacn 16 | from gi.repository import GLib 17 | from olc.backends import DMXBackend 18 | from olc.define import NB_UNIVERSES, UNIVERSES, App 19 | 20 | 21 | class Sacn(DMXBackend): 22 | """Sacn Backend""" 23 | 24 | sender: sacn.sACNsender 25 | receiver: sacn.sACNreceiver 26 | old_frame: list[tuple[int]] 27 | 28 | def __init__(self, patch): 29 | self.sender = sacn.sACNsender() 30 | self.sender.start() 31 | self.receiver = sacn.sACNreceiver() 32 | self.receiver.start() 33 | for universe in UNIVERSES: 34 | self.sender.activate_output(universe) 35 | self.sender[universe].multicast = True 36 | self.receiver.join_multicast(universe) 37 | self.receiver.register_listener("universe", 38 | self.receive_packet, 39 | universe=universe) 40 | self.old_frame = [(0, ) * 512 for _ in range(NB_UNIVERSES)] 41 | super().__init__(patch) 42 | 43 | def stop(self) -> None: 44 | """Stop sACN backend""" 45 | super().stop() 46 | self.sender.stop() 47 | for universe in UNIVERSES: 48 | self.receiver.leave_multicast(universe) 49 | self.receiver.stop() 50 | 51 | def send(self, universe: int, index: int) -> None: 52 | """Send sACN universe 53 | 54 | Args: 55 | universe: one in UNIVERSES 56 | index: Index of universe 57 | """ 58 | self.sender[universe].dmx_data = tuple(self.dmx.frame[index]) 59 | 60 | def receive_packet(self, packet: sacn.DataPacket) -> None: 61 | """Callback when receive sACN packets 62 | 63 | Args: 64 | packet: sACN data 65 | """ 66 | univ = packet.universe 67 | idx = UNIVERSES.index(univ) 68 | if App().tabs.tabs["patch_outputs"]: 69 | # Find diff between old and new DMX frames 70 | outputs = [ 71 | index 72 | for index, (e1, 73 | e2) in enumerate(zip(packet.dmxData, self.old_frame[idx])) 74 | if e1 != e2 75 | ] 76 | # Loop on outputs with different level 77 | for output in outputs: 78 | if self.patch.outputs.get(univ) and self.patch.outputs[univ].get( 79 | output + 1): 80 | GLib.idle_add( 81 | App().tabs.tabs["patch_outputs"].outputs[output + 82 | (idx * 83 | 512)].queue_draw) 84 | # Save DMX frame for next call 85 | self.old_frame[idx] = packet.dmxData 86 | -------------------------------------------------------------------------------- /src/cue.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from typing import Dict 16 | 17 | from olc.define import MAX_CHANNELS 18 | 19 | 20 | class Cue: 21 | """Cue/Preset object 22 | A Cue or a Preset is used to store intensities for playback in a Sequence. 23 | A Cue is attached to a sequence and a Preset is a global memory 24 | """ 25 | 26 | sequence: int # Sequence number (0 for Preset) 27 | memory: float # Cue number 28 | channels: Dict[int, int] # Channels levels 29 | text: str # Cue text 30 | 31 | def __init__( 32 | self, 33 | sequence, 34 | memory, 35 | channels=None, 36 | text="", 37 | ): 38 | self.sequence = sequence 39 | self.memory = memory 40 | self.channels = channels or {} 41 | self.text = text 42 | 43 | def set_level(self, channel: int, level: int) -> None: 44 | """Set level of a channel. 45 | 46 | Args : 47 | channel: channel number (1-MAX_CHANNELS) 48 | level: level (0 - 255) 49 | """ 50 | if (isinstance(level, int) and 0 <= level < 256 and isinstance(channel, int) 51 | and 0 < channel <= MAX_CHANNELS): 52 | self.channels[channel] = level 53 | 54 | def get_level(self, channel: int) -> int: 55 | """Get channel's level 56 | 57 | Args: 58 | channel: channel number (1-MAX_CHANNELS) 59 | 60 | Returns: 61 | channel's level (0-255) 62 | """ 63 | return self.channels.get(channel, 0) 64 | -------------------------------------------------------------------------------- /src/define.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | """Some defines for olc project.""" 16 | import unicodedata 17 | from typing import Any 18 | 19 | from gi.repository import Gio 20 | 21 | UNIVERSES = [1, 2, 3, 4] 22 | NB_UNIVERSES = len(UNIVERSES) 23 | 24 | MAX_CHANNELS = 1024 25 | # Can't have more channels than outputs 26 | MAX_CHANNELS = min(MAX_CHANNELS, NB_UNIVERSES * 512) 27 | 28 | MAX_FADER_PAGE = 10 29 | MAX_FADER_PER_PAGE = 10 30 | 31 | App = Gio.Application.get_default 32 | 33 | # Send DMX every DMX_INTERVAL (milliseconds) 34 | DMX_INTERVAL = 25 35 | 36 | 37 | def is_float(element: Any) -> bool: 38 | """Test if argument is a float 39 | 40 | Args: 41 | element: argument to test 42 | 43 | Returns: 44 | True or False 45 | """ 46 | try: 47 | float(element) 48 | return True 49 | except ValueError: 50 | return False 51 | 52 | 53 | def is_non_nul_float(element: Any) -> bool: 54 | """Test if argument is a float and non null 55 | 56 | Args: 57 | element: argument to test 58 | 59 | Returns: 60 | True or False 61 | """ 62 | return bool(float(element)) if is_float(element) else False 63 | 64 | 65 | def is_int(element: Any) -> bool: 66 | """Test if argument is an integer 67 | 68 | Args: 69 | element: argument to test 70 | 71 | Returns: 72 | True or False 73 | """ 74 | try: 75 | int(element) 76 | return True 77 | except ValueError: 78 | return False 79 | 80 | 81 | def is_non_nul_int(element: Any) -> bool: 82 | """Test if argument is an integer and non null 83 | 84 | Args: 85 | element: argument to test 86 | 87 | Returns: 88 | True or False 89 | """ 90 | return bool(int(element)) if is_int(element) else False 91 | 92 | 93 | def time_to_string(time: float) -> str: 94 | """A number of seconds to human readable text 95 | 96 | Args: 97 | time: seconds 98 | 99 | Returns: 100 | Human readable time ([[hours:]minutes:]seconds[.tenths]) 101 | """ 102 | minutes, seconds = divmod(time, 60) 103 | hours, minutes = divmod(minutes, 60) 104 | string = "" 105 | if hours: 106 | string += f"{int(hours)}:" 107 | if minutes: 108 | string += f"{int(minutes)}:" 109 | if seconds.is_integer(): 110 | string += f"{int(seconds)}" 111 | else: 112 | string += f"{seconds}" 113 | if string == "0": 114 | string = "" 115 | return string 116 | 117 | 118 | def string_to_time(string: str) -> float: 119 | """Convert a string time to float 120 | 121 | Args: 122 | string: format = [[hours:]minutes:]seconds[.tenths] 123 | 124 | Returns: 125 | time in seconds 126 | """ 127 | if string == "": 128 | string = "0" 129 | if ":" in string: 130 | tsplit = string.split(":") 131 | if len(tsplit) == 2: 132 | time = int(tsplit[0]) * 60 + float(tsplit[1]) 133 | elif len(tsplit) == 3: 134 | time = int(tsplit[0]) * 3600 + int(tsplit[1]) * 60 + float(tsplit[2]) 135 | else: 136 | time = 0.0 137 | elif is_float(string): 138 | time = float(string) 139 | else: 140 | time = 0.0 141 | return time 142 | 143 | 144 | def strip_accents(text: str) -> str: 145 | """Remove accents from characters 146 | 147 | Args: 148 | text: Text to modify 149 | 150 | Returns: 151 | Text without accents 152 | """ 153 | text = unicodedata.normalize("NFD", text).encode("ascii", "ignore").decode("utf-8") 154 | return str(text) 155 | -------------------------------------------------------------------------------- /src/dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gtk 16 | from olc.define import App 17 | 18 | 19 | class ConfirmationDialog(Gtk.Dialog): 20 | """Confirmation dialog""" 21 | 22 | def __init__(self, text: str): 23 | super().__init__(title="Confirmation", transient_for=App().window, flags=0) 24 | self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, 25 | Gtk.ResponseType.OK) 26 | 27 | self.set_default_size(150, 100) 28 | 29 | label = Gtk.Label(label=text) 30 | 31 | box = self.get_content_area() 32 | box.add(label) 33 | self.show_all() 34 | -------------------------------------------------------------------------------- /src/dmx.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import array 16 | from typing import Any, Optional 17 | 18 | from olc.define import DMX_INTERVAL, MAX_CHANNELS, NB_UNIVERSES, UNIVERSES, App 19 | from olc.main_fader import MainFader 20 | from olc.patch import DMXPatch 21 | from olc.timer import RepeatedTimer 22 | 23 | 24 | class Dmx: 25 | """Send levels to backend""" 26 | 27 | backend: Any 28 | patch: DMXPatch 29 | main_fader: MainFader 30 | levels: dict[str, array.array] 31 | frame: list[array.array] 32 | user_outputs: dict[tuple[int, int], int] 33 | thread: RepeatedTimer 34 | 35 | def __init__(self, backend): 36 | self.backend = backend 37 | self.patch = App().lightshow.patch 38 | self.main_fader = MainFader() 39 | # Dimmers levels 40 | self.levels = { 41 | "sequence": array.array("B", [0] * MAX_CHANNELS), 42 | "user": array.array("h", [-1] * MAX_CHANNELS), 43 | "faders": array.array("B", [0] * MAX_CHANNELS), 44 | } 45 | # DMX values 46 | self.frame = [array.array("B", [0] * 512) for _ in range(NB_UNIVERSES)] 47 | self._old_frame = [array.array("B", [0] * 512) for _ in range(NB_UNIVERSES)] 48 | # To test outputs 49 | self.user_outputs = {} 50 | # Thread to send DMX every DMX_INTERVAL ms 51 | self.thread = RepeatedTimer(DMX_INTERVAL / 1000, self.send) 52 | 53 | def set_levels(self, channels: Optional[set[int]] = None) -> None: 54 | """Set DMX frame levels 55 | 56 | Args: 57 | channels: Channels to modify 58 | """ 59 | if not channels: 60 | channels = set(range(1, MAX_CHANNELS + 1)) 61 | for channel in channels: 62 | if not self.patch.is_patched(channel): 63 | continue 64 | outputs = self.patch.channels[channel] 65 | channel -= 1 66 | # Sequence 67 | level = self.levels["sequence"][channel] 68 | # User 69 | if not App( 70 | ).lightshow.main_playback.on_go and self.levels["user"][channel] != -1: 71 | level = self.levels["user"][channel] 72 | # Faders 73 | if self.levels["faders"][channel] > level: 74 | level = self.levels["faders"][channel] 75 | # Independents 76 | if App().lightshow.independents.dmx[channel] > level: 77 | level = App().lightshow.independents.dmx[channel] 78 | for out in outputs: 79 | output = out[0] 80 | universe = out[1] 81 | # Curve 82 | curve_numb = self.patch.outputs[universe][output][1] 83 | if curve_numb: 84 | curve = App().lightshow.curves.get_curve(curve_numb) 85 | level = curve.values.get(level, 0) 86 | # Main Fader 87 | level = round(level * self.main_fader.value) 88 | # Update output level 89 | index = UNIVERSES.index(universe) 90 | self.frame[index][output - 1] = level 91 | 92 | def send(self) -> None: 93 | """Send DMX values to Ola""" 94 | if self.backend: 95 | for index, universe in enumerate(UNIVERSES): 96 | self.backend.send(universe, index) 97 | 98 | def all_outputs_at_zero(self) -> None: 99 | """All DMX outputs to 0""" 100 | for index, universe in enumerate(UNIVERSES): 101 | self.frame[index] = array.array("B", [0] * 512) 102 | self.backend.send(universe, index) 103 | 104 | def send_user_output(self, output: int, universe: int, level: int) -> None: 105 | """Send level to an output 106 | 107 | Args: 108 | output: Output number (1-512) 109 | universe: Universe number (one in UNIVERSES) 110 | level: Output level (0-255) 111 | """ 112 | self.user_outputs[(output, universe)] = level 113 | index = UNIVERSES.index(universe) 114 | self.frame[index][output - 1] = level 115 | if not level: 116 | self.user_outputs.pop((output, universe)) 117 | self.backend.send(universe, index) 118 | -------------------------------------------------------------------------------- /src/fader_bank.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | # from olc.define import MAX_FADER_PAGE 16 | from typing import Any 17 | 18 | from olc.define import MAX_FADER_PAGE, MAX_FADER_PER_PAGE, App 19 | from olc.fader import (Fader, FaderChannels, FaderGroup, FaderMain, FaderPreset, 20 | FaderSequence, FaderType) 21 | 22 | 23 | class FaderBank: 24 | """Pages of faders""" 25 | 26 | active_page: int 27 | faders: dict 28 | channels: set 29 | active_faders: set 30 | max_fader_per_page: int 31 | 32 | def __init__(self): 33 | self.active_page = 1 34 | self.faders = {} 35 | self.max_fader_per_page = MAX_FADER_PER_PAGE 36 | for page in range(1, MAX_FADER_PAGE + 1): 37 | self.faders[page] = {} 38 | for index in range(1, MAX_FADER_PER_PAGE + 1): 39 | self.faders[page][index] = Fader(index, self) 40 | self.channels = set() 41 | self.active_faders = set() 42 | self.update_active_faders() 43 | 44 | def get_fader(self, index: int) -> Fader: 45 | """Get fader on active page 46 | 47 | Args: 48 | index: Fader index 49 | 50 | Returns: 51 | Fader object 52 | """ 53 | return self.faders[self.active_page][index] 54 | 55 | def reset_faders(self) -> None: 56 | """Reset all faders""" 57 | self.active_page = 1 58 | for page in range(1, MAX_FADER_PAGE + 1): 59 | for index in range(1, MAX_FADER_PER_PAGE + 1): 60 | self.set_fader(page, index, FaderType.NONE) 61 | self.channels = set() 62 | self.active_faders = set() 63 | self.update_active_faders() 64 | 65 | def get_fader_type(self, page: int, index: int) -> FaderType: 66 | """Get Fader type 67 | 68 | Args: 69 | page: Fader page 70 | index: Fader index 71 | 72 | Returns: 73 | Fader type 74 | """ 75 | if isinstance(self.faders[page][index], FaderGroup): 76 | return FaderType.GROUP 77 | if isinstance(self.faders[page][index], FaderChannels): 78 | return FaderType.CHANNELS 79 | if isinstance(self.faders[page][index], FaderPreset): 80 | return FaderType.PRESET 81 | if isinstance(self.faders[page][index], FaderSequence): 82 | return FaderType.SEQUENCE 83 | if isinstance(self.faders[page][index], FaderMain): 84 | return FaderType.MAIN 85 | return FaderType.NONE 86 | 87 | def set_fader(self, 88 | page: int, 89 | index: int, 90 | fader_type: FaderType, 91 | contents: Any = None) -> None: 92 | """Assign a fader 93 | 94 | Args: 95 | page: Fader page 96 | index: Fader index 97 | fader_type: Fader type 98 | contents: Fader contents 99 | """ 100 | if fader_type == self.get_fader_type(page, index): 101 | self._set_fader_contents(page, index, fader_type, contents) 102 | else: 103 | self._set_fader_type(page, index, fader_type, contents) 104 | 105 | def _set_fader_type(self, page: int, index: int, fader_type: FaderType, 106 | contents: Any) -> None: 107 | if fader_type == FaderType.NONE: 108 | self.faders[page][index] = Fader(index, self) 109 | self.faders[page][index].set_level(0) 110 | elif fader_type == FaderType.CHANNELS: 111 | self.faders[page][index] = FaderChannels(index, self, contents) 112 | self.faders[page][index].set_level(0) 113 | elif fader_type == FaderType.GROUP: 114 | if group := App().lightshow.get_group(contents): 115 | self.faders[page][index] = FaderGroup(index, self, group) 116 | else: 117 | self.faders[page][index] = FaderGroup(index, self) 118 | self.faders[page][index].set_level(0) 119 | elif fader_type == FaderType.MAIN: 120 | self.faders[page][index] = FaderMain(index, self) 121 | elif fader_type == FaderType.PRESET: 122 | if cue := App().lightshow.get_cue(contents): 123 | self.faders[page][index] = FaderPreset(index, self, cue) 124 | else: 125 | self.faders[page][index] = FaderPreset(index, self) 126 | self.faders[page][index].set_level(0) 127 | elif fader_type == FaderType.SEQUENCE: 128 | if chaser := App().lightshow.get_chaser(contents): 129 | self.faders[page][index] = FaderSequence(index, self, chaser) 130 | else: 131 | self.faders[page][index] = FaderSequence(index, self) 132 | self.faders[page][index].set_level(0) 133 | self._refresh_faders_display(page, index) 134 | 135 | def _set_fader_contents(self, page: int, index: int, fader_type: FaderType, 136 | contents: Any) -> None: 137 | if fader_type == FaderType.GROUP: 138 | if group := App().lightshow.get_group(contents): 139 | self.faders[page][index].set_contents(group) 140 | elif fader_type == FaderType.PRESET: 141 | if cue := App().lightshow.get_cue(contents): 142 | self.faders[page][index].set_contents(cue) 143 | elif fader_type == FaderType.SEQUENCE: 144 | if chaser := App().lightshow.get_chaser(contents): 145 | self.faders[page][index].set_contents(chaser) 146 | self._refresh_faders_display(page, index) 147 | 148 | def _refresh_faders_display(self, page: int, index: int) -> None: 149 | if page == self.active_page: 150 | # Refresh MIDI 151 | App().midi.update_fader(self.faders[page][index]) 152 | # Refresh Virtual Console 153 | if App().virtual_console: 154 | widget = App().virtual_console.faders[self.faders[page][index].index - 155 | 1] 156 | level = self.faders[page][index].level * 255 157 | widget.set_value(level) 158 | App().virtual_console.fader_moved(widget) 159 | App().virtual_console.flashes[self.faders[page][index].index - 160 | 1].label = self.faders[page][index].text 161 | App().virtual_console.flashes[self.faders[page][index].index - 162 | 1].queue_draw() 163 | # Refresh OSC 164 | if App().osc: 165 | App().osc.client.send("/olc/fader/page", ("i", page)) 166 | App().osc.client.send(f"/olc/fader/1/{index}/label", 167 | ("s", self.faders[page][index].text)) 168 | App().osc.client.send( 169 | f"olc/fader/1/{index}/level", 170 | ("i", round(self.faders[page][index].level * 255))) 171 | 172 | def update_active_faders(self) -> None: 173 | """List faders with channels levels""" 174 | self.active_faders = set() 175 | for page in self.faders.values(): 176 | for fader in page.values(): 177 | if isinstance(fader, 178 | (FaderGroup, FaderPreset, FaderChannels, FaderSequence)): 179 | self.active_faders.add(fader) 180 | self.channels = self.channels | fader.channels 181 | 182 | def update_levels(self) -> None: 183 | """Update faders levels for DMX""" 184 | for channel in self.channels: 185 | if not App().lightshow.patch.is_patched(channel): 186 | continue 187 | level_fader = -1 188 | for fader in self.active_faders: 189 | if fader.dmx[channel - 1] > level_fader: 190 | level_fader = fader.dmx[channel - 1] 191 | if level_fader != -1: 192 | App().backend.dmx.levels["faders"][channel - 1] = level_fader 193 | -------------------------------------------------------------------------------- /src/files/export_file.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gio 16 | from olc.files.ascii.writer import AsciiWriter 17 | from olc.files.file_type import FileType 18 | from olc.files.olc.writer import OlcWriter 19 | 20 | 21 | class ExportFile: 22 | """Export file""" 23 | 24 | file: Gio.File 25 | file_type: FileType 26 | writer: AsciiWriter 27 | 28 | def __init__(self, file: Gio.File, file_type: FileType): 29 | self.file = file 30 | self.file_type = file_type 31 | 32 | if self.file_type is FileType.ASCII: 33 | self.writer = AsciiWriter(self.file) 34 | else: 35 | self.writer = OlcWriter(self.file) 36 | 37 | def write(self) -> None: 38 | """Write file""" 39 | self.writer.write() 40 | 41 | def get_file_type(self) -> str: 42 | """Get file type 43 | 44 | Returns: 45 | "ascii" or "olc" 46 | """ 47 | return self.file_type 48 | -------------------------------------------------------------------------------- /src/files/file_type.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from enum import Enum, auto 16 | 17 | 18 | class FileType(Enum): 19 | """File types""" 20 | 21 | ASCII = auto() 22 | OLC = auto() 23 | -------------------------------------------------------------------------------- /src/files/import_dialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from __future__ import annotations 16 | 17 | from enum import Enum, auto 18 | from gettext import gettext as _ 19 | 20 | from gi.repository import Gtk 21 | 22 | 23 | class Action(Enum): 24 | """Action type""" 25 | 26 | REPLACE = auto() 27 | MERGE = auto() 28 | IGNORE = auto() 29 | 30 | 31 | class DialogData(Gtk.Dialog): 32 | """Dialog to choose data to import""" 33 | 34 | actions: dict 35 | 36 | def __init__(self, parent, data, actions): 37 | super().__init__(title=_("Data to import"), transient_for=parent, flags=0) 38 | 39 | self.actions = actions 40 | 41 | self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, 42 | Gtk.ResponseType.OK) 43 | box = self.get_content_area() 44 | box.set_spacing(6) 45 | # If you change actions order, you have to modify combo callbacks 46 | actions = ["Replace", "Merge", "Ignore"] 47 | if data["patch"]: 48 | self._patch(actions, box) 49 | if data["sequences"]: 50 | self._sequences(actions, box, data) 51 | if data["groups"]: 52 | self._groups(actions, box) 53 | if data["independents"]: 54 | self._independents(actions, box) 55 | if data["faders"]: 56 | self._faders(actions, box) 57 | self.show_all() 58 | 59 | def _patch(self, actions: list, box: Gtk.Box) -> None: 60 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 61 | label = Gtk.Label("Patch:") 62 | combo = Gtk.ComboBoxText() 63 | combo.connect("changed", self._on_patch_changed) 64 | for action in actions: 65 | combo.append_text(action) 66 | combo.set_active(0) 67 | hbox.pack_start(label, True, True, 0) 68 | hbox.add(combo) 69 | box.add(hbox) 70 | 71 | def _sequences(self, actions: list, box: Gtk.Box, data: dict) -> None: 72 | box.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) 73 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 74 | label = Gtk.Label("Sequences:") 75 | hbox.pack_start(label, False, True, 0) 76 | box.add(hbox) 77 | combos = {} 78 | for sequence in data["sequences"].keys(): 79 | self.actions["sequences"][sequence] = Action.REPLACE 80 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 81 | if sequence == 1: 82 | name = "MainPlayback" 83 | elif data["sequences"][sequence].get("label"): 84 | name = data["sequences"][sequence]["label"] 85 | else: 86 | name = str(sequence) 87 | label = Gtk.Label(name) 88 | combos[sequence] = Gtk.ComboBoxText() 89 | combos[sequence].connect("changed", self._on_seq_changed, sequence) 90 | for action in actions: 91 | combos[sequence].append_text(action) 92 | combos[sequence].set_active(0) 93 | hbox.pack_start(label, True, True, 0) 94 | hbox.add(combos[sequence]) 95 | box.add(hbox) 96 | 97 | def _groups(self, actions: list, box: Gtk.Box) -> None: 98 | box.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) 99 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 100 | label = Gtk.Label("Groups:") 101 | combo = Gtk.ComboBoxText() 102 | combo.connect("changed", self._on_groups_changed) 103 | for action in actions: 104 | combo.append_text(action) 105 | combo.set_active(0) 106 | hbox.pack_start(label, True, True, 0) 107 | hbox.add(combo) 108 | box.add(hbox) 109 | 110 | def _faders(self, actions: list, box: Gtk.Box) -> None: 111 | box.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) 112 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 113 | label = Gtk.Label("Faders:") 114 | combo = Gtk.ComboBoxText() 115 | combo.connect("changed", self._on_faders_changed) 116 | for action in actions: 117 | combo.append_text(action) 118 | combo.set_active(0) 119 | hbox.pack_start(label, True, True, 0) 120 | hbox.add(combo) 121 | box.add(hbox) 122 | 123 | def _independents(self, actions: list, box: Gtk.Box) -> None: 124 | box.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) 125 | hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 126 | label = Gtk.Label("Independents:") 127 | combo = Gtk.ComboBoxText() 128 | combo.connect("changed", self._on_independents_changed) 129 | for action in actions: 130 | combo.append_text(action) 131 | combo.set_active(0) 132 | hbox.pack_start(label, True, True, 0) 133 | hbox.add(combo) 134 | box.add(hbox) 135 | 136 | def _on_patch_changed(self, widget): 137 | active = widget.get_active() 138 | if active == 0: 139 | self.actions["patch"] = Action.REPLACE 140 | elif active == 1: 141 | self.actions["patch"] = Action.MERGE 142 | elif active == 2: 143 | self.actions["patch"] = Action.IGNORE 144 | 145 | def _on_seq_changed(self, widget, sequence): 146 | active = widget.get_active() 147 | if active == 0: 148 | self.actions["sequences"][sequence] = Action.REPLACE 149 | elif active == 1: 150 | self.actions["sequences"][sequence] = Action.MERGE 151 | elif active == 2: 152 | self.actions["sequences"][sequence] = Action.IGNORE 153 | 154 | def _on_groups_changed(self, widget): 155 | active = widget.get_active() 156 | if active == 0: 157 | self.actions["groups"] = Action.REPLACE 158 | elif active == 1: 159 | self.actions["groups"] = Action.MERGE 160 | elif active == 2: 161 | self.actions["groups"] = Action.IGNORE 162 | 163 | def _on_faders_changed(self, widget): 164 | active = widget.get_active() 165 | if active == 0: 166 | self.actions["faders"] = Action.REPLACE 167 | elif active == 1: 168 | self.actions["faders"] = Action.MERGE 169 | elif active == 2: 170 | self.actions["faders"] = Action.IGNORE 171 | 172 | def _on_independents_changed(self, widget): 173 | active = widget.get_active() 174 | if active == 0: 175 | self.actions["independents"] = Action.REPLACE 176 | elif active == 1: 177 | self.actions["independents"] = Action.MERGE 178 | elif active == 2: 179 | self.actions["independents"] = Action.IGNORE 180 | -------------------------------------------------------------------------------- /src/files/import_file.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gio, Gtk 16 | from olc.cue import Cue 17 | from olc.define import App 18 | from olc.files.ascii.parser import AsciiParser 19 | from olc.files.file_type import FileType 20 | from olc.files.import_dialog import Action, DialogData 21 | from olc.files.olc.parser import OlcParser 22 | from olc.files.parsed_data import ParsedData 23 | from olc.independent import Independents 24 | from olc.step import Step 25 | 26 | 27 | class ImportFile: 28 | """Import file""" 29 | 30 | file: Gio.File 31 | file_type: FileType 32 | data = ParsedData 33 | actions: dict 34 | parser: AsciiParser 35 | 36 | def __init__(self, file: Gio.File, file_type: FileType, importation: bool = False): 37 | self.file = file 38 | self.file_type = file_type 39 | self.data = ParsedData() 40 | self.actions = { 41 | "curves": Action.REPLACE, 42 | "patch": Action.REPLACE, 43 | "sequences": {}, 44 | "groups": Action.REPLACE, 45 | "independents": Action.REPLACE, 46 | "faders": Action.REPLACE, 47 | "midi": Action.REPLACE 48 | } 49 | 50 | if self.file_type is FileType.ASCII: 51 | if App(): 52 | default_time = App().settings.get_double("default-time") 53 | else: 54 | default_time = 5.0 55 | self.parser = AsciiParser(self, default_time, importation=importation) 56 | else: 57 | self.parser = OlcParser(self, importation=importation) 58 | 59 | def parse(self) -> None: 60 | """Start reading file""" 61 | self.parser.read() 62 | App().lightshow.add_recent_file() 63 | 64 | def load_all(self) -> None: 65 | """Load all file""" 66 | for sequence in self.data.data["sequences"]: 67 | self.actions["sequences"][sequence] = Action.REPLACE 68 | self._do_import() 69 | App().lightshow.set_not_modified() 70 | 71 | def select_data(self) -> None: 72 | """Select data to import""" 73 | dialog = DialogData(App().window, self.data.data, self.actions) 74 | response = dialog.run() 75 | dialog.destroy() 76 | if response == Gtk.ResponseType.OK: 77 | self._do_import() 78 | App().lightshow.set_modified() 79 | 80 | def _do_import(self): 81 | self._do_import_curves() 82 | self._do_import_patch() 83 | self._do_import_sequences() 84 | self._do_import_groups() 85 | self._do_import_independents() 86 | self._do_import_presets() 87 | self._do_import_faders() 88 | if self.file_type is FileType.OLC: 89 | self._do_import_midi() 90 | self._update_ui() 91 | 92 | def _do_import_curves(self) -> None: 93 | if self.actions["curves"] is Action.IGNORE: 94 | return 95 | if self.actions["curves"] is Action.REPLACE: 96 | App().lightshow.curves.reset() 97 | self.data.import_curves() 98 | 99 | def _do_import_patch(self) -> None: 100 | if self.actions["patch"] is Action.IGNORE: 101 | return 102 | if self.actions["patch"] is Action.REPLACE: 103 | App().lightshow.patch.patch_empty() 104 | # Import patch 105 | self.data.import_patch() 106 | 107 | def _do_import_groups(self) -> None: 108 | if self.actions["groups"] is Action.IGNORE: 109 | return 110 | if self.actions["groups"] is Action.REPLACE: 111 | del App().lightshow.groups[:] 112 | self.data.import_groups() 113 | 114 | def _do_import_faders(self) -> None: 115 | if self.actions["faders"] is Action.IGNORE: 116 | return 117 | if self.actions["faders"] is Action.REPLACE: 118 | App().lightshow.fader_bank.reset_faders() 119 | self.data.import_faders(self.actions) 120 | 121 | def _do_import_independents(self) -> None: 122 | if self.actions["independents"] is Action.IGNORE: 123 | return 124 | if self.actions["independents"] is Action.REPLACE: 125 | App().lightshow.independents = Independents() 126 | self.data.import_independents() 127 | 128 | def _do_import_presets(self) -> None: 129 | # Presets (Cues not in a sequence) are attached to Main Playback 130 | if self.actions["sequences"].get(1) is Action.IGNORE: 131 | return 132 | self.data.import_presets() 133 | 134 | def _do_import_sequences(self) -> None: 135 | for sequence in self.actions["sequences"]: 136 | if self.actions["sequences"][sequence] is Action.IGNORE: 137 | continue 138 | if self.actions["sequences"][sequence] is Action.REPLACE: 139 | self._clear_sequence(sequence) 140 | # Import sequence 141 | if sequence == 1: 142 | self.data.import_main_playback(sequence, 143 | self.actions["sequences"][sequence]) 144 | # Add empty step at the end 145 | cue = Cue(0, 0.0) 146 | step = Step(sequence, cue=cue) 147 | App().lightshow.main_playback.add_step(step) 148 | # Main playback at start 149 | App().lightshow.main_playback.position = 0 150 | else: 151 | self.data.import_chaser(sequence, self.actions["sequences"][sequence]) 152 | 153 | def _clear_sequence(self, sequence: int) -> None: 154 | if sequence == 1: 155 | del App().lightshow.cues[:] 156 | del App().lightshow.main_playback.steps[1:] 157 | else: 158 | chaser = None 159 | for chsr in App().lightshow.chasers: 160 | if chsr.index == sequence: 161 | chaser = chsr 162 | break 163 | if chaser: 164 | App().lightshow.chasers.remove(chaser) 165 | 166 | def _do_import_midi(self) -> None: 167 | if self.actions["midi"] is Action.IGNORE: 168 | return 169 | if self.actions["midi"] is Action.REPLACE: 170 | App().midi.reset_messages() 171 | self.data.import_midi() 172 | 173 | def _update_ui(self) -> None: 174 | App().window.live_view.channels_view.update() 175 | App().tabs.refresh_all() 176 | subtitle = (f"Mem. : 0.0 - " 177 | f"Next Mem. : {App().lightshow.main_playback.steps[1].cue.memory} " 178 | f"{App().lightshow.main_playback.steps[1].cue.text}") 179 | App().window.header.set_subtitle(subtitle) 180 | App().window.playback.update_xfade_display(0) 181 | App().window.playback.update_sequence_display() 182 | # Redraw Mackie LCD 183 | App().midi.messages.lcd.show_faders() 184 | -------------------------------------------------------------------------------- /src/files/olc/parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from __future__ import annotations 16 | 17 | import json 18 | import typing 19 | 20 | from olc.define import is_float, is_int 21 | from olc.files.read import ReadFile 22 | 23 | if typing.TYPE_CHECKING: 24 | from olc.files.import_file import ImportFile 25 | from olc.files.parsed_data import ParsedData 26 | 27 | 28 | class OlcParser(ReadFile): 29 | """Parse olc files""" 30 | 31 | data: ParsedData 32 | 33 | def __init__(self, imported: ImportFile, importation: bool = False): 34 | super().__init__(imported, compressed=True, importation=importation) 35 | self.data = imported.data.data 36 | 37 | def parse(self) -> None: 38 | """Parse file""" 39 | try: 40 | contents = json.loads(self.contents, object_hook=self._key_to_number) 41 | except json.decoder.JSONDecodeError: 42 | self._error_dialog("Input file is not a valid file: JSONDecodeError") 43 | return 44 | self.data["curves"] = contents.get("curves") 45 | self.data["patch"] = contents.get("patch") 46 | self.data["sequences"] = contents.get("sequences") 47 | self.data["presets"] = contents.get("cues") 48 | self.data["groups"] = contents.get("groups") 49 | self.data["faders"] = contents.get("faders") 50 | self.data["independents"] = contents.get("independents") 51 | self.data["midi"] = contents.get("midi_mapping") 52 | 53 | def _int_float_str(self, key): 54 | if is_int(key): 55 | return int(key) 56 | if is_float(key): 57 | return float(key) 58 | return key 59 | 60 | def _key_to_number(self, obj): 61 | return {self._int_float_str(k): v for k, v in obj.items()} 62 | -------------------------------------------------------------------------------- /src/files/read.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from __future__ import annotations 16 | 17 | import gzip 18 | import typing 19 | from gettext import gettext as _ 20 | 21 | from charset_normalizer import from_bytes 22 | from gi.repository import GLib, Gtk 23 | from olc.define import App 24 | 25 | if typing.TYPE_CHECKING: 26 | from gi.repository import Gio 27 | from olc.files.import_file import ImportFile 28 | 29 | 30 | class ReadFile: 31 | """Read file 32 | 33 | This class must be sub-classed and parse implemented 34 | """ 35 | 36 | imported: ImportFile 37 | compressed: bool 38 | contents: str 39 | 40 | def __init__(self, 41 | imported: ImportFile, 42 | compressed: bool = False, 43 | importation: bool = False): 44 | self.imported = imported 45 | self.compressed = compressed 46 | self.importation = importation 47 | self.contents = "" 48 | 49 | def _load_cb(self, file: Gio.File, result: Gio.AsyncResult, user_data=None) -> None: 50 | try: 51 | _success, data, _etag = file.load_contents_finish(result) 52 | except GLib.GError as error: 53 | self._error_dialog(str(error)) 54 | return 55 | if self.compressed: 56 | try: 57 | data = gzip.decompress(data) 58 | except gzip.BadGzipFile: 59 | self._error_dialog("Input file is not a valid file: BadGzipFile") 60 | return 61 | self.contents = str(from_bytes(data).best()) 62 | self.parse() 63 | self.imported.data.clean() 64 | if self.importation: 65 | self.imported.select_data() 66 | else: 67 | self.imported.load_all() 68 | 69 | def read(self) -> None: 70 | """Read all file""" 71 | self.imported.file.load_contents_async(None, self._load_cb, None) 72 | 73 | def parse(self) -> None: 74 | """Parse file 75 | 76 | Raises: 77 | NotImplementedError: Must be implemented in subclass 78 | """ 79 | raise NotImplementedError 80 | 81 | def _error_dialog(self, message: str) -> None: 82 | dialog = Gtk.MessageDialog( 83 | App().window, Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, 84 | Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, message) 85 | dialog.set_title(_("Error")) 86 | dialog.run() 87 | dialog.destroy() 88 | -------------------------------------------------------------------------------- /src/files/write.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gio 16 | from olc.define import App 17 | 18 | 19 | class WriteFile: 20 | """Write file 21 | 22 | This class must be sub-classed and export implemented 23 | """ 24 | 25 | file: Gio.File 26 | compressed: bool 27 | stream: Gio.FileOutputStream 28 | 29 | def __init__(self, file: Gio.File, compressed: bool = False): 30 | self.file = file 31 | self.compressed = compressed 32 | self.stream = None 33 | 34 | def write(self) -> None: 35 | """Write file""" 36 | output_stream = self.file.replace("", False, Gio.FileCreateFlags.NONE, None) 37 | if self.compressed: 38 | converter = Gio.ZlibCompressor.new(Gio.ZlibCompressorFormat.GZIP, -1) 39 | self.stream = Gio.ConverterOutputStream.new(output_stream, converter) 40 | else: 41 | self.stream = output_stream 42 | # Write data 43 | self.export() 44 | self.stream.close() 45 | App().lightshow.set_not_modified() 46 | App().lightshow.add_recent_file() 47 | 48 | def export(self) -> None: 49 | """Export file 50 | 51 | Raises: 52 | NotImplementedError: Must be implemented in subclass 53 | """ 54 | raise NotImplementedError 55 | -------------------------------------------------------------------------------- /src/independent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import array 16 | 17 | from olc.define import MAX_CHANNELS, App 18 | 19 | 20 | class Independent: 21 | """Independent object 22 | Control channels excluded from recording 23 | 24 | Attributes: 25 | number (int): independent number 26 | level (int): independent level (0-255) 27 | channels (set): channels present in independent 28 | levels (Dict[int, int]): channels levels 29 | text (str): independent text 30 | inde_type (str): knob or button 31 | dmx (array): DMX levels 32 | """ 33 | 34 | def __init__( 35 | self, 36 | number, 37 | text="", 38 | levels=None, 39 | inde_type="knob", 40 | ): 41 | self.number = number 42 | self.level = 0 43 | self.channels = set() 44 | self.levels = levels or {} 45 | self.text = text 46 | self.inde_type = inde_type 47 | self.dmx = array.array("B", [0] * MAX_CHANNELS) 48 | 49 | self.update_channels() 50 | 51 | def update_channels(self): 52 | """Update set of channels""" 53 | for channel, level in self.levels.items(): 54 | if level: 55 | self.channels.add(channel) 56 | 57 | def set_levels(self, levels): 58 | """Define channels and levels 59 | 60 | Args: 61 | levels (Dict[int, int]): channels levels 62 | """ 63 | self.levels = levels 64 | self.channels = {channel for channel, level in levels.items() if level} 65 | 66 | def set_level(self, value: int) -> None: 67 | """Set independent level 68 | 69 | Args: 70 | value: New level 71 | """ 72 | # Send MIDI message to knob LEDs 73 | App().midi.messages.control_change.send(f"inde_led_{self.number}", 32 + int( 74 | (value / 255) * 12)) 75 | self.level = value 76 | self.update_dmx() 77 | 78 | def update_dmx(self): 79 | """Update DMX levels""" 80 | for channel, level in self.levels.items(): 81 | dmx_lvl = round(level * (self.level / 255)) 82 | self.dmx[channel - 1] = dmx_lvl 83 | App().lightshow.independents.update_dmx() 84 | App().backend.dmx.set_levels(self.channels) 85 | 86 | 87 | class Independents: 88 | """All independents 89 | 90 | Attributes: 91 | independents (list): list of independents 92 | channels (set): list of channels present in independents 93 | """ 94 | 95 | def __init__(self): 96 | self.independents = [] 97 | self.channels = set() 98 | self.dmx = array.array("B", [0] * MAX_CHANNELS) 99 | 100 | # Create 9 Independents 101 | for i in range(6): 102 | self.add(Independent(i + 1)) 103 | for i in range(6, 9): 104 | self.add(Independent(i + 1, inde_type="button")) 105 | 106 | def update_dmx(self): 107 | """Update DMX levels""" 108 | for channel in self.channels: 109 | channel -= 1 110 | level_inde = -1 111 | for inde in self.independents: 112 | if channel + 1 in inde.channels and inde.dmx[channel] > level_inde: 113 | level_inde = inde.dmx[channel] 114 | if level_inde != -1: 115 | self.dmx[channel] = level_inde 116 | next_level = App().lightshow.main_playback.get_next_channel_level( 117 | channel + 1, level_inde) 118 | App().window.live_view.update_channel_widget(channel + 1, next_level) 119 | 120 | def add(self, independent): 121 | """Add an independent 122 | 123 | Args: 124 | independent: Independent object 125 | 126 | Returns: 127 | True or False 128 | """ 129 | number = independent.number 130 | for inde in self.independents: 131 | if inde.number == number: 132 | print("Independent already exist") 133 | return False 134 | self.independents.append(independent) 135 | self.update_channels() 136 | return True 137 | 138 | def update(self, independent): 139 | """Update independent 140 | 141 | Args: 142 | independent: Independent object 143 | """ 144 | number = independent.number 145 | text = independent.text 146 | levels = independent.levels 147 | self.independents[number - 1].text = text 148 | self.independents[number - 1].set_levels(levels) 149 | self.update_channels() 150 | 151 | def get_channels(self): 152 | """ 153 | Returns: 154 | (set) channels presents in all independent 155 | """ 156 | return self.channels 157 | 158 | def update_channels(self): 159 | """Update set of channels present in all independents""" 160 | self.channels = set() 161 | for inde in self.independents: 162 | self.channels = self.channels | inde.channels 163 | -------------------------------------------------------------------------------- /src/lightshow.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from __future__ import annotations 16 | 17 | import typing 18 | 19 | from gi.repository import GLib, Gtk 20 | from olc.curve import Curves 21 | from olc.define import UNIVERSES, App 22 | from olc.fader_bank import FaderBank 23 | from olc.independent import Independents 24 | from olc.patch import DMXPatch 25 | from olc.sequence import Sequence 26 | 27 | if typing.TYPE_CHECKING: 28 | from gi.repository import Gio 29 | from olc.cue import Cue 30 | from olc.group import Group 31 | 32 | 33 | class ShowFile: 34 | """Opened file""" 35 | 36 | file: Gio.File 37 | basename: str 38 | modified: bool 39 | recent_manager: Gtk.RecentManager 40 | 41 | def __init__(self, file): 42 | self.file = file 43 | self.basename = self.file.get_basename() if file else "" 44 | self.modified = False 45 | self.recent_manager = Gtk.RecentManager.get_default() 46 | 47 | def add_recent_file(self) -> None: 48 | """Add to Recent files 49 | 50 | Raises: 51 | e: not documented 52 | """ 53 | if not self.file: 54 | return 55 | uri = self.file.get_uri() 56 | if uri: 57 | # We remove the project from recent projects list 58 | # and then re-add it to this list to make sure it 59 | # gets positioned at the top of the recent projects list. 60 | try: 61 | self.recent_manager.remove_item(uri) 62 | except GLib.Error as e: 63 | if e.domain != "gtk-recent-manager-error-quark": 64 | raise e 65 | self.recent_manager.add_item(uri) 66 | 67 | def set_modified(self) -> None: 68 | """Set file as modified""" 69 | self.modified = True 70 | self.basename = self.file.get_basename() if self.file else "" 71 | App().window.header.set_title(f"{self.basename}*") 72 | 73 | def set_not_modified(self) -> None: 74 | """Set file as not modified""" 75 | self.modified = False 76 | self.basename = self.file.get_basename() if self.file else "" 77 | App().window.header.set_title(self.basename) 78 | 79 | 80 | class LightShow(ShowFile): 81 | """Light show data""" 82 | 83 | curves: Curves 84 | main_playback: Sequence 85 | cues: list 86 | chasers: list 87 | groups: list 88 | fader_bank: FaderBank 89 | independents: Independents 90 | patch: DMXPatch 91 | 92 | def __init__(self): 93 | super().__init__(None) 94 | # Curves 95 | self.curves = Curves() 96 | # Main Playback 97 | self.main_playback = Sequence(1, text="Main Playback") 98 | # List of global memories 99 | self.cues = [] 100 | # List of chasers 101 | self.chasers = [] 102 | # List of groups 103 | self.groups = [] 104 | # Faders 105 | self.fader_bank = FaderBank() 106 | # Independents 107 | self.independents = Independents() 108 | # Patch 109 | self.patch = DMXPatch(UNIVERSES) 110 | 111 | def get_cue(self, number: float) -> None | Cue: 112 | """Get Cue with his number 113 | 114 | Args: 115 | number: Cue number 116 | 117 | Returns: 118 | Cue or None 119 | """ 120 | for cue in self.cues: 121 | if cue.memory == number: 122 | return cue 123 | return None 124 | 125 | def get_group(self, number: float) -> None | Group: 126 | """Get Group with his number 127 | 128 | Args: 129 | number: Group number 130 | 131 | Returns: 132 | Group or None 133 | """ 134 | for group in self.groups: 135 | if group.index == number: 136 | return group 137 | return None 138 | 139 | def get_chaser(self, number: float) -> None | Sequence: 140 | """Get Chaser with his number 141 | 142 | Args: 143 | number: Chaser number 144 | 145 | Returns: 146 | Chaser or None 147 | """ 148 | for chaser in self.chasers: 149 | if chaser.index == number: 150 | return chaser 151 | return None 152 | 153 | def reset(self) -> None: 154 | """Reset all""" 155 | del self.main_playback.steps[1:] 156 | del self.cues[:] 157 | for chaser in self.chasers: 158 | if chaser.run and chaser.thread: 159 | chaser.run = False 160 | chaser.thread.stop() 161 | chaser.thread.join() 162 | del self.chasers[:] 163 | del self.groups[:] 164 | self.fader_bank.reset_faders() 165 | self.independents = Independents() 166 | self.patch.patch_empty() 167 | -------------------------------------------------------------------------------- /src/main_fader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | 16 | 17 | class MainFader: 18 | """Main Fader""" 19 | 20 | value: float 21 | 22 | def __init__(self): 23 | self.value = 1.0 24 | 25 | def set_level(self, value: float) -> None: 26 | """Set Main Fader level 27 | 28 | Args: 29 | value: New level (0 - 1) 30 | """ 31 | self.value = value 32 | 33 | def get_level(self) -> float: 34 | """Get Main Fader level 35 | 36 | Returns: 37 | level (0 - 1) 38 | """ 39 | return self.value 40 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | moduledir = join_paths(pkgdatadir, 'olc') 2 | gnome = import('gnome') 3 | 4 | python = import('python') 5 | 6 | #conf = configuration_data() 7 | #conf.set('PYTHON', python.find_installation('python3').path()) 8 | #conf.set('VERSION', meson.project_version()) 9 | #conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir'))) 10 | #conf.set('pkgdatadir', pkgdatadir) 11 | 12 | configure_file( 13 | input: 'olc.in', 14 | output: 'olc', 15 | configuration: conf, 16 | install: true, 17 | install_dir: get_option('bindir') 18 | ) 19 | 20 | olc_sources = [ 21 | 'application.py', 22 | 'channel_time.py', 23 | 'crossfade.py', 24 | 'cue.py', 25 | 'cues_edition.py', 26 | 'curve.py', 27 | 'curve_edition.py', 28 | 'define.py', 29 | 'dialog.py', 30 | 'dmx.py', 31 | 'fader.py', 32 | 'fader_edition.py', 33 | 'fader_bank.py', 34 | 'group.py', 35 | 'independent.py', 36 | 'independents_edition.py', 37 | 'lightshow.py', 38 | 'main_fader.py', 39 | 'osc.py', 40 | 'patch.py', 41 | 'patch_channels.py', 42 | 'patch_outputs.py', 43 | 'sequence.py', 44 | 'sequence_edition.py', 45 | 'settings.py', 46 | 'step.py', 47 | 'tabs_manager.py', 48 | 'timer.py', 49 | 'track_channels.py', 50 | 'virtual_console.py', 51 | 'window.py', 52 | 'window_channels.py', 53 | 'window_playback.py', 54 | 'zoom.py', 55 | ] 56 | 57 | install_data( 58 | olc_sources, 59 | install_dir: join_paths(python_dir, 'olc') 60 | ) 61 | 62 | install_subdir( 63 | 'backends', 64 | install_dir: join_paths(python_dir, 'olc') 65 | ) 66 | 67 | install_subdir( 68 | 'files', 69 | install_dir: join_paths(python_dir, 'olc') 70 | ) 71 | 72 | install_subdir( 73 | 'midi', 74 | install_dir: join_paths(python_dir, 'olc') 75 | ) 76 | 77 | install_subdir( 78 | 'widgets', 79 | install_dir: join_paths(python_dir, 'olc') 80 | ) 81 | -------------------------------------------------------------------------------- /src/midi/fader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from enum import Enum, auto 16 | 17 | 18 | class FaderState(Enum): 19 | """MIDI fader states""" 20 | 21 | VALID = auto() 22 | UP = auto() 23 | DOWN = auto() 24 | 25 | 26 | class MIDIFader: 27 | """MIDI fader""" 28 | 29 | value: float 30 | valid: FaderState 31 | 32 | def __init__(self): 33 | self.value = 0 34 | self.valid = FaderState.VALID 35 | 36 | def get_value(self) -> float: 37 | """Get fader value 38 | 39 | Returns: 40 | Fader value 41 | """ 42 | return self.value 43 | 44 | def set_state(self, value: int) -> None: 45 | """Set Fader state 46 | 47 | Args: 48 | value: Value of object attached to MIDI fader (Fader, MainFader, 49 | Independent) 50 | """ 51 | if value > self.value: 52 | self.valid = FaderState.UP 53 | elif value < self.value: 54 | self.valid = FaderState.DOWN 55 | else: 56 | self.valid = FaderState.VALID 57 | 58 | def is_valid(self, new_value: int, level: int) -> bool: 59 | """Is fader valid 60 | 61 | Args: 62 | new_value: New value 63 | level: level to compare 64 | 65 | Returns: 66 | True if anchored, else False 67 | """ 68 | self.value = new_value 69 | if (self.valid is FaderState.UP 70 | and self.value < level) or (self.valid is FaderState.DOWN 71 | and self.value > level): 72 | return False 73 | self.valid = FaderState.VALID 74 | return True 75 | -------------------------------------------------------------------------------- /src/midi/lcd.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from __future__ import annotations 16 | 17 | import typing 18 | 19 | import mido 20 | from olc.define import App, strip_accents 21 | 22 | if typing.TYPE_CHECKING: 23 | from olc.fader import Fader 24 | 25 | 26 | class MackieLCD: 27 | """To display on LCD of Mackie Control""" 28 | 29 | def __init__(self): 30 | pass 31 | 32 | def send(self, text: str, line: int) -> None: 33 | """Send text to LCD 34 | 35 | Args: 36 | text: Text to send 37 | line: 0 (first line) or 1 (second line) 38 | """ 39 | if line not in (0, 1): 40 | return 41 | text = strip_accents(text) 42 | chars = [ord(c) for c in text] 43 | start = line * 56 44 | data = [0, 0, 102, 20, 18, start] + chars 45 | msg = mido.Message("sysex", data=data) 46 | App().midi.enqueue(msg) 47 | 48 | def send_to_strip(self, text: str, line: int, strip: int) -> None: 49 | """Send text to LCD 50 | 51 | Args: 52 | text: Text to send 53 | line: 0 (first line) or 1 (second line) 54 | strip: Strip number (0 - 7) 55 | """ 56 | if line not in (0, 1): 57 | return 58 | text = strip_accents(text) 59 | text = f"{text.ljust(7)}" 60 | text = text[:-1] + "|" 61 | chars = [ord(c) for c in text] 62 | start = (line * 56) + (strip * 7) 63 | data = [0, 0, 102, 20, 18, start] + chars 64 | msg = mido.Message("sysex", data=data) 65 | App().midi.enqueue(msg) 66 | 67 | def clear(self) -> None: 68 | """Clear LCD""" 69 | text = 56 * " " 70 | self.send(text, 0) 71 | self.send(text, 1) 72 | 73 | def show_faders(self) -> None: 74 | """Show faders name""" 75 | fader_bank = App().lightshow.fader_bank 76 | for fader in fader_bank.faders[fader_bank.active_page].values(): 77 | if fader.index <= 8: 78 | text = fader.text[:7] 79 | strip = fader.index - 1 80 | self.send_to_strip(text, 0, strip) 81 | self.show_page_number() 82 | 83 | def show_fader(self, fader: Fader) -> None: 84 | """Show fader name 85 | 86 | Args: 87 | fader: Fader to display 88 | """ 89 | if fader.index <= 8: 90 | text = fader.text[:7] 91 | strip = fader.index - 1 92 | self.send_to_strip(text, 0, strip) 93 | 94 | def show_page_number(self) -> None: 95 | """Show fader page number""" 96 | text = 56 * " " 97 | self.send(text, 1) 98 | text = f"Page {App().lightshow.fader_bank.active_page}" 99 | chars = [ord(c) for c in text] 100 | start = 56 + int((56 - len(text)) / 2) 101 | data = [0, 0, 102, 20, 18, start] + chars 102 | msg = mido.Message("sysex", data=data) 103 | App().midi.enqueue(msg) 104 | -------------------------------------------------------------------------------- /src/midi/pitchwheel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from typing import Dict 16 | 17 | import mido 18 | from gi.repository import GLib 19 | from olc.define import App 20 | 21 | 22 | class MidiPitchWheel: 23 | """MIDI pitchwheel messages from controllers""" 24 | 25 | pitchwheel: Dict[str, int] 26 | 27 | def __init__(self): 28 | # Default MIDI pitchwheel values : "action": Channel 29 | self.pitchwheel = { 30 | "crossfade_out": -1, 31 | "crossfade_in": -1, 32 | } 33 | for i in range(10): 34 | for j in range(9): 35 | self.pitchwheel[f"fader_{j + i * 10 + 1}"] = j 36 | 37 | def reset(self) -> None: 38 | """Remove all MIDI pitchwheel""" 39 | for action in self.pitchwheel: 40 | self.pitchwheel[action] = -1 41 | 42 | def scan(self, msg: mido.Message) -> None: 43 | """Scan MIDI pitchwheel messages 44 | 45 | Args: 46 | msg: MIDI message 47 | """ 48 | for key, value in self.pitchwheel.items(): 49 | if msg.channel == value: 50 | if key[:6] == "fader_": 51 | _update_fader(msg, int(key[6:]) - 1) 52 | break 53 | if key[:13] == "crossfade_out": 54 | GLib.idle_add(App().midi.xfade.moved, msg, 55 | App().midi.xfade.fader_out) 56 | elif key[:12] == "crossfade_in": 57 | GLib.idle_add(App().midi.xfade.moved, msg, 58 | App().midi.xfade.fader_in) 59 | elif func := getattr(self, f"_function_{key}", None): 60 | GLib.idle_add(func, None, msg) 61 | 62 | def send(self, midi_name: str, value: int) -> None: 63 | """Send MIDI pitchwheel message 64 | 65 | Args: 66 | midi_name: action string 67 | value: value to send 68 | """ 69 | channel = self.pitchwheel.get(midi_name, -1) 70 | if channel != -1: 71 | msg = mido.Message("pitchwheel", channel=channel, pitch=value, time=0) 72 | App().midi.enqueue(msg) 73 | 74 | def learn(self, msg: mido.Message, learning: str) -> None: 75 | """Learn new MIDI Pitchwheel control 76 | 77 | Args: 78 | msg: MIDI message 79 | learning: action to update 80 | """ 81 | if self.pitchwheel.get(learning): 82 | for key, channel in self.pitchwheel.items(): 83 | if channel == msg.channel: 84 | self.pitchwheel.update({key: -1}) 85 | self.pitchwheel.update({learning: msg.channel}) 86 | 87 | 88 | def _update_fader(msg: mido.Message, index: int) -> None: 89 | val = (msg.pitch + 8192) / 16383 90 | if App().virtual_console: 91 | GLib.idle_add(App().virtual_console.faders[index].set_value, val * 255) 92 | GLib.idle_add(App().virtual_console.fader_moved, 93 | App().virtual_console.faders[index]) 94 | else: 95 | number = index + 1 96 | fader = App().lightshow.fader_bank.get_fader(number) 97 | GLib.idle_add(fader.set_level, val) 98 | -------------------------------------------------------------------------------- /src/midi/ports.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from typing import Optional 16 | 17 | import mido 18 | from gi.repository import GLib 19 | from olc.define import App 20 | from olc.timer import RepeatedTimer 21 | 22 | 23 | class MidiIO: 24 | """A thin wrapper around mido IO port""" 25 | 26 | name: Optional[str] # The port name 27 | port: mido.ports.BaseInput # The port itself 28 | 29 | def __init__(self, name: Optional[str] = None) -> None: 30 | self.name = name 31 | self.port = mido.open_ioport(name=self.name, callback=self.receive_callback) 32 | 33 | def __del__(self) -> None: 34 | self.port.callback = None 35 | self.port.close() 36 | 37 | def close(self) -> None: 38 | """Close open MIDI port and delete Callback""" 39 | self.port.callback = None 40 | self.port.close() 41 | 42 | def receive_callback(self, msg: mido.Message) -> None: 43 | """Scan MIDI messages. 44 | Executed with mido callback, in another thread 45 | 46 | Args: 47 | msg: MIDI message 48 | """ 49 | # print(self.name, msg) 50 | if App().midi.learning: 51 | App().midi.learn(msg) 52 | 53 | # Find action 54 | if msg.type in ("note_on", "note_off"): 55 | App().midi.messages.notes.scan(msg) 56 | elif msg.type == "control_change": 57 | App().midi.messages.control_change.scan(self.name, msg) 58 | elif msg.type == "pitchwheel": 59 | App().midi.messages.pitchwheel.scan(msg) 60 | 61 | 62 | class MidiPorts: 63 | """MIDI In and Out ports""" 64 | 65 | ports: list[MidiIO] 66 | 67 | def __init__(self): 68 | self.ports = [] 69 | self.mido_ports = None 70 | 71 | ports = App().settings.get_strv("midi-ports") 72 | self.mido_ports = mido.get_ioport_names() 73 | self.open(ports) 74 | 75 | self.poll = RepeatedTimer(1, self.polling) 76 | 77 | def polling(self) -> None: 78 | """Poll MIDI ports""" 79 | port_names = mido.get_ioport_names() 80 | if port_names != self.mido_ports: 81 | self.mido_ports = port_names 82 | self.open(App().settings.get_strv("midi-ports")) 83 | App().midi.update_faders() 84 | if App().tabs.tabs["settings"]: 85 | GLib.idle_add(App().tabs.tabs["settings"].refresh) 86 | 87 | def open(self, ports: list[str]) -> None: 88 | """Open MIDI IO 89 | 90 | Args: 91 | ports: MIDI ports to open 92 | """ 93 | for port in ports: 94 | if port in self.mido_ports: 95 | ioport = MidiIO(port) 96 | self.ports.append(ioport) 97 | 98 | def close(self) -> None: 99 | """Close MIDI inputs""" 100 | for port in self.ports: 101 | self.ports.remove(port) 102 | port.close() 103 | -------------------------------------------------------------------------------- /src/midi/xfade.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import mido 16 | from olc.define import App 17 | 18 | from .fader import MIDIFader 19 | 20 | 21 | class MidiXFade: 22 | """MIDI manual Crossfade""" 23 | 24 | def __init__(self): 25 | self.fader_in = MIDIFader() 26 | self.fader_out = MIDIFader() 27 | self.inverted = True 28 | 29 | def get_inverted(self) -> bool: 30 | """ 31 | Returns: 32 | inverted status 33 | """ 34 | return self.inverted 35 | 36 | def set_inverted(self, invert: bool) -> None: 37 | """Set inverted status 38 | 39 | Args: 40 | invert: True or False 41 | """ 42 | self.inverted = invert 43 | 44 | def moved(self, msg: mido.Message, midi_fader) -> None: 45 | """Fader moved 46 | 47 | Args: 48 | msg: MIDI message 49 | midi_fader: Crossfade MIDI fader 50 | """ 51 | App().midi.enqueue(msg) 52 | if midi_fader is self.fader_in: 53 | xfade_val = App().crossfade.scale_b.value 54 | else: 55 | xfade_val = App().crossfade.scale_a.value 56 | if msg.type == "pitchwheel": 57 | val = (msg.pitch + 8192) / 16383 58 | elif msg.type == "control_change": 59 | val = msg.value / 127 60 | if self.get_inverted(): 61 | val = val * 255 62 | else: 63 | val = abs((val - 1) * 255) 64 | if not midi_fader.is_valid(val, xfade_val): 65 | return 66 | self._xfade(midi_fader, val) 67 | 68 | def _xfade(self, fader: MIDIFader, value: float) -> None: 69 | """Manual Crossfade 70 | 71 | Args: 72 | fader : In or Out 73 | value : fader value (0 - 255) 74 | """ 75 | App().crossfade.manual = True 76 | 77 | if self.fader_out.get_value() == 255 and self.fader_in.get_value() == 255: 78 | if self.get_inverted(): 79 | self.set_inverted(False) 80 | self.set_inverted(False) 81 | else: 82 | self.set_inverted(True) 83 | self.set_inverted(True) 84 | self.fader_out.value = 0 85 | self.fader_in.value = 0 86 | 87 | if fader == self.fader_out: 88 | if App().virtual_console: 89 | App().virtual_console.scale_a.set_value(value) 90 | else: 91 | App().crossfade.scale_a.set_value(value) 92 | App().crossfade.scale_moved(App().crossfade.scale_a) 93 | elif fader == self.fader_in: 94 | if App().virtual_console: 95 | App().virtual_console.scale_b.set_value(value) 96 | else: 97 | App().crossfade.scale_b.set_value(value) 98 | App().crossfade.scale_moved(App().crossfade.scale_b) 99 | -------------------------------------------------------------------------------- /src/olc.in: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # Open Lighting Console 4 | # Copyright (c) 2015-2024 Mika Cousin 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program. If not, see . 16 | 17 | import gettext 18 | import locale 19 | import os 20 | import sys 21 | 22 | from gi.repository import Gio 23 | 24 | # Make sure we'll find all modules 25 | sys.path.insert(1, "@pythondir@") 26 | 27 | from olc.application import Application # noqa: E402 28 | 29 | LOCALEDIR = "@localedir@" 30 | PKGDATADIR = "@pkgdatadir@" 31 | 32 | if __name__ == "__main__": 33 | locale.bindtextdomain("olc", LOCALEDIR) 34 | locale.textdomain("olc") 35 | gettext.bindtextdomain("olc", LOCALEDIR) 36 | gettext.textdomain("olc") 37 | 38 | resource = Gio.resource_load(os.path.join(PKGDATADIR, "olc.gresource")) 39 | Gio.Resource._register(resource) 40 | 41 | app = Application("@REVISION@") 42 | 43 | exit_status = app.run(sys.argv) 44 | 45 | sys.exit(exit_status) 46 | -------------------------------------------------------------------------------- /src/step.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | class Step: 16 | """Step 17 | A Step is used to store times and a Cue in a Sequence 18 | """ 19 | 20 | def __init__( 21 | self, 22 | sequence=0, 23 | cue=None, 24 | time_in=5.0, 25 | time_out=5.0, 26 | delay_in=0.0, 27 | delay_out=0.0, 28 | wait=0.0, 29 | channel_time=None, 30 | text="", 31 | ): 32 | self.sequence = sequence 33 | self.cue = cue 34 | self.time_in = time_in 35 | self.time_out = time_out 36 | self.delay_in = delay_in 37 | self.delay_out = delay_out 38 | self.wait = wait 39 | if channel_time is None: 40 | channel_time = {} 41 | self.channel_time = channel_time 42 | self.text = text 43 | 44 | self.update_total_time() 45 | 46 | def update_total_time(self): 47 | """Calculate Total Time""" 48 | if self.time_in + self.delay_in > self.time_out + self.delay_out: 49 | self.total_time = self.time_in + self.delay_in + self.wait 50 | else: 51 | self.total_time = self.time_out + self.delay_out + self.wait 52 | 53 | for channel in self.channel_time.keys(): 54 | self.total_time = max( 55 | self.total_time, self.channel_time[channel].delay + 56 | self.channel_time[channel].time + self.wait) 57 | 58 | def set_time_in(self, time_in): 59 | """Set Time In 60 | 61 | Args: 62 | time_in: Time In 63 | """ 64 | self.time_in = time_in 65 | self.update_total_time() 66 | 67 | def set_time_out(self, time_out): 68 | """Set Time Out 69 | 70 | Args: 71 | time_out: Time Out 72 | """ 73 | self.time_out = time_out 74 | self.update_total_time() 75 | 76 | def set_delay_in(self, delay_in): 77 | """Set Delay In 78 | 79 | Args: 80 | delay_in: Delay In 81 | """ 82 | self.delay_in = delay_in 83 | self.update_total_time() 84 | 85 | def set_delay_out(self, delay_out): 86 | """Set Delay Out 87 | 88 | Args: 89 | delay_out: Delay Out 90 | """ 91 | self.delay_out = delay_out 92 | self.update_total_time() 93 | 94 | def set_wait(self, wait): 95 | """Set Wait 96 | 97 | Args: 98 | wait: Wait 99 | """ 100 | self.wait = wait 101 | self.update_total_time() 102 | 103 | def set_time(self, time): 104 | """Set Time In and Time Out 105 | 106 | Args: 107 | time: Time 108 | """ 109 | self.time_in = time 110 | self.time_out = time 111 | self.update_total_time() 112 | 113 | def set_delay(self, delay): 114 | """Set Delay In and Out 115 | 116 | Args: 117 | delay: Delay 118 | """ 119 | self.delay_in = delay 120 | self.delay_out = delay 121 | self.update_total_time() 122 | -------------------------------------------------------------------------------- /src/tabs_manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from typing import Any, Dict, Optional 16 | 17 | from gi.repository import Gtk 18 | from olc.define import App 19 | 20 | 21 | class Tabs: 22 | """Tabs manager 23 | 24 | Attributes: 25 | tabs: Tabs defined by a unique name and widgets 26 | """ 27 | 28 | tabs: Dict[str, Optional[Any]] 29 | 30 | def __init__(self): 31 | self.tabs = { 32 | "channel_time": None, 33 | "curves": None, 34 | "faders": None, 35 | "groups": None, 36 | "indes": None, 37 | "memories": None, 38 | "patch_outputs": None, 39 | "patch_channels": None, 40 | "sequences": None, 41 | "settings": None, 42 | "track_channels": None, 43 | } 44 | 45 | def open(self, tab_name: str, widget: Any, label: str, *args) -> None: 46 | """Open tab 47 | 48 | Args: 49 | tab_name: Tab name found in self.tabs 50 | widget: Widget to open 51 | label: Tab label 52 | *args: additional parameters 53 | """ 54 | if self.tabs[tab_name] is None: 55 | self.tabs[tab_name] = widget(*args) 56 | # Label with a close icon 57 | button = Gtk.Button() 58 | button.set_relief(Gtk.ReliefStyle.NONE) 59 | button.add(Gtk.Image.new_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU)) 60 | button.connect( 61 | "clicked", 62 | self.tabs[tab_name].on_close_icon # type: ignore[union-attr] 63 | ) 64 | newlabel = Gtk.Box() 65 | newlabel.pack_start(Gtk.Label(label), False, False, 0) 66 | newlabel.pack_start(button, False, False, 0) 67 | newlabel.show_all() 68 | App().window.playback.append_page(self.tabs[tab_name], newlabel) 69 | App().window.playback.set_tab_reorderable(self.tabs[tab_name], True) 70 | App().window.playback.set_tab_detachable(self.tabs[tab_name], True) 71 | App().window.show_all() 72 | App().window.playback.set_current_page(-1) 73 | else: 74 | page = App().window.playback.page_num(self.tabs[tab_name]) 75 | App().window.playback.set_current_page(page) 76 | App().window.playback.grab_focus() 77 | 78 | def close(self, tab_name: str) -> None: 79 | """Close tab 80 | 81 | Args: 82 | tab_name : Tab name found in self.tabs 83 | """ 84 | if self.tabs[tab_name]: 85 | App().window.commandline.set_string("") 86 | notebook = self.tabs[tab_name].get_parent() # type: ignore[union-attr] 87 | page = notebook.page_num(self.tabs[tab_name]) 88 | notebook.remove_page(page) 89 | self.tabs[tab_name] = None 90 | 91 | def refresh_all(self) -> None: 92 | """Refresh all open tabs""" 93 | for tab in self.tabs.values(): 94 | if tab: 95 | tab.refresh() 96 | -------------------------------------------------------------------------------- /src/timer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import threading 16 | import time 17 | 18 | 19 | class RepeatedTimer: 20 | """Call a function every 'interval' seconds""" 21 | 22 | def __init__(self, interval, function, *args, **kwargs): 23 | self._timer = None 24 | self.interval = interval 25 | self.function = function 26 | self.args = args 27 | self.kwargs = kwargs 28 | self.is_running = False 29 | self.next_call = time.time() 30 | self.start() 31 | 32 | def _run(self) -> None: 33 | self.is_running = False 34 | self.start() 35 | self.function(*self.args, **self.kwargs) 36 | 37 | def start(self) -> None: 38 | """Start function""" 39 | if not self.is_running: 40 | self.next_call += self.interval 41 | self._timer = threading.Timer(self.next_call - time.time(), self._run) 42 | self._timer.start() 43 | self.is_running = True 44 | 45 | def stop(self) -> None: 46 | """Stop function""" 47 | self._timer.cancel() 48 | self.is_running = False 49 | -------------------------------------------------------------------------------- /src/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikacousin/olc/c8192c6aa16554fddb73ef745cb71fe587a72312/src/widgets/__init__.py -------------------------------------------------------------------------------- /src/widgets/button.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import cairo 16 | from gi.repository import Gdk, GObject, Gtk 17 | from olc.define import App 18 | 19 | from .common import rounded_rectangle, rounded_rectangle_fill 20 | 21 | 22 | class ButtonWidget(Gtk.Widget): 23 | """Button widget""" 24 | 25 | __gtype_name__ = "ButtonWidget" 26 | 27 | __gsignals__ = {"clicked": (GObject.SIGNAL_ACTION, None, ())} 28 | 29 | def __init__(self, label="", text="None"): 30 | Gtk.Widget.__init__(self) 31 | 32 | self.width = 50 33 | self.height = 50 34 | self.radius = 10 35 | self.font_size = 10 36 | 37 | self.pressed = False 38 | self.label = label 39 | self.text = text 40 | 41 | self.set_size_request(self.width, self.height) 42 | 43 | self.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) 44 | 45 | self.connect("button-press-event", self.on_press) 46 | self.connect("button-release-event", self.on_release) 47 | 48 | def on_press(self, _tgt, _ev): 49 | """Button pressed""" 50 | self.pressed = True 51 | self.queue_draw() 52 | self.emit("clicked") 53 | 54 | def on_release(self, _tgt, _ev): 55 | """Button released""" 56 | self.pressed = False 57 | self.queue_draw() 58 | 59 | def do_draw(self, cr): 60 | """Draw button 61 | 62 | Args: 63 | cr: Cairo context 64 | """ 65 | # Draw rounded box 66 | if self.text == "None": 67 | cr.set_source_rgb(0.4, 0.4, 0.4) 68 | elif self.pressed: 69 | if App().midi.learning == self.text: 70 | cr.set_source_rgb(0.2, 0.1, 0.1) 71 | else: 72 | cr.set_source_rgb(0.5, 0.3, 0.0) 73 | elif App().midi.learning == self.text: 74 | cr.set_source_rgb(0.3, 0.2, 0.2) 75 | else: 76 | cr.set_source_rgb(0.2, 0.2, 0.2) 77 | area = (1, self.width - 2, 1, self.height - 2) 78 | rounded_rectangle_fill(cr, area, self.radius) 79 | cr.set_source_rgb(0.1, 0.1, 0.1) 80 | rounded_rectangle(cr, area, self.radius) 81 | # Draw Text 82 | if self.text == "None": 83 | cr.set_source_rgb(0.5, 0.5, 0.5) 84 | else: 85 | cr.set_source_rgb(0.8, 0.8, 0.8) 86 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) 87 | cr.set_font_size(self.font_size) 88 | (_x, _y, w, h, _dx, _dy) = cr.text_extents(self.label) 89 | cr.move_to(self.width / 2 - w / 2, 90 | self.height / 2 - (h - (self.radius * 2)) / 2) 91 | cr.show_text(self.label) 92 | 93 | def do_realize(self): 94 | """Realize widget""" 95 | allocation = self.get_allocation() 96 | attr = Gdk.WindowAttr() 97 | attr.window_type = Gdk.WindowType.CHILD 98 | attr.x = allocation.x 99 | attr.y = allocation.y 100 | attr.width = allocation.width 101 | attr.height = allocation.height 102 | attr.visual = self.get_visual() 103 | attr.event_mask = (self.get_events() 104 | | Gdk.EventMask.EXPOSURE_MASK 105 | | Gdk.EventMask.BUTTON_PRESS_MASK 106 | | Gdk.EventMask.TOUCH_MASK) 107 | wat = Gdk.WindowAttributesType 108 | mask = wat.X | wat.Y | wat.VISUAL 109 | 110 | window = Gdk.Window(self.get_parent_window(), attr, mask) 111 | self.set_window(window) 112 | self.register_window(window) 113 | 114 | self.set_realized(True) 115 | window.set_background_pattern(None) 116 | -------------------------------------------------------------------------------- /src/widgets/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import math 16 | 17 | 18 | def rounded_rectangle_fill(cr, area, radius): 19 | """Draw a filled rounded box 20 | 21 | Args: 22 | cr: cairo context 23 | area: coordinates (top, bottom, left, right) 24 | radius: arc's radius 25 | """ 26 | a, b, c, d = area 27 | cr.arc(a + radius, c + radius, radius, 2 * (math.pi / 2), 3 * (math.pi / 2)) 28 | cr.arc(b - radius, c + radius, radius, 3 * (math.pi / 2), 4 * (math.pi / 2)) 29 | cr.arc(b - radius, d - radius, radius, 0 * (math.pi / 2), 1 * (math.pi / 2)) 30 | cr.arc(a + radius, d - radius, radius, 1 * (math.pi / 2), 2 * (math.pi / 2)) 31 | cr.close_path() 32 | cr.fill() 33 | 34 | 35 | def rounded_rectangle(cr, area, radius): 36 | """Draw a rounded box 37 | 38 | Args: 39 | cr: cairo context 40 | area: coordinates (top, bottom, left, right) 41 | radius: arc's radius 42 | """ 43 | a, b, c, d = area 44 | cr.arc(a + radius, c + radius, radius, 2 * (math.pi / 2), 3 * (math.pi / 2)) 45 | cr.arc(b - radius, c + radius, radius, 3 * (math.pi / 2), 4 * (math.pi / 2)) 46 | cr.arc(b - radius, d - radius, radius, 0 * (math.pi / 2), 1 * (math.pi / 2)) 47 | cr.arc(a + radius, d - radius, radius, 1 * (math.pi / 2), 2 * (math.pi / 2)) 48 | cr.close_path() 49 | cr.stroke() 50 | -------------------------------------------------------------------------------- /src/widgets/controller.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import math 16 | 17 | from gi.repository import Gdk, GObject, Gtk 18 | from olc.define import App 19 | 20 | 21 | class ControllerWidget(Gtk.DrawingArea): 22 | """Controller widget, inherits from Gtk.DrawingArea 23 | 24 | Attributes: 25 | angle (int): Controller angle (from -360 to 360) 26 | led (bool): Display LED 27 | """ 28 | 29 | __gtype_name__ = "ControllerWidget" 30 | 31 | __gsignals__ = { 32 | "moved": ( 33 | GObject.SignalFlags.RUN_FIRST, 34 | None, 35 | ( 36 | Gdk.ScrollDirection, 37 | int, 38 | ), 39 | ), 40 | "clicked": (GObject.SignalFlags.ACTION, None, ()), 41 | } 42 | 43 | def __init__(self, text="None"): 44 | Gtk.DrawingArea.__init__(self) 45 | 46 | self.angle = 0 47 | self.led = False 48 | self.text = text 49 | 50 | # Mouse position when button clicked 51 | self.x1 = 0 52 | self.y1 = 0 53 | self.old_angle = 0 54 | 55 | self.add_events(Gdk.EventMask.SCROLL_MASK) 56 | self.connect("scroll-event", self.on_scroll) 57 | self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) 58 | self.connect("button-press-event", self.on_press) 59 | self.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) 60 | self.connect("button-release-event", self.on_release) 61 | self.add_events(Gdk.EventMask.BUTTON1_MOTION_MASK) 62 | self.connect("motion-notify-event", self.on_motion) 63 | 64 | def on_press(self, _tgt, event): 65 | """Mouse button pressed 66 | 67 | Args: 68 | event: Gdk.Event 69 | """ 70 | self.x1 = event.x 71 | self.y1 = event.y 72 | self.old_angle = math.radians(self.angle) 73 | 74 | def on_release(self, _tgt, _ev): 75 | """Mouse button released""" 76 | self.old_angle = 0 77 | self.emit("clicked") 78 | 79 | def on_motion(self, _tgt, event): 80 | """Track mouse to rotate controller 81 | 82 | Args: 83 | event: Gdk.Event 84 | """ 85 | # Center 86 | x0 = self.get_allocation().width / 2 87 | y0 = self.get_allocation().height / 2 88 | # Actual position 89 | x2 = event.x 90 | y2 = event.y 91 | # Angle of movement 92 | y = (self.x1 - x0) * (y2 - y0) - (self.y1 - y0) * (x2 - x0) 93 | x = (self.x1 - x0) * (x2 - x0) + (self.y1 - y0) * (y2 - y0) 94 | angle = math.atan2(y, x) 95 | if angle > math.pi / 2: 96 | self.old_angle = self.old_angle + math.pi / 2 97 | angle = angle - math.pi / 2 98 | self.x1 = x2 99 | self.y1 = y2 100 | elif angle < -math.pi / 2: 101 | self.old_angle = self.old_angle - math.pi / 2 102 | angle = angle + math.pi / 2 103 | self.x1 = x2 104 | self.y1 = y2 105 | self.angle = math.degrees(self.old_angle + angle) 106 | step = math.degrees(abs(angle)) * (100 / 360) 107 | if angle > 0: 108 | self.emit("moved", Gdk.ScrollDirection.UP, step) 109 | else: 110 | self.emit("moved", Gdk.ScrollDirection.DOWN, step) 111 | 112 | def on_scroll(self, _widget, event): 113 | """On scroll wheel event 114 | 115 | Args: 116 | event: Gdk.Event 117 | """ 118 | accel_mask = Gtk.accelerator_get_default_mod_mask() 119 | step = 1 if event.state & accel_mask == Gdk.ModifierType.SHIFT_MASK else 10 120 | (scroll, direction) = event.get_scroll_direction() 121 | if scroll: 122 | if direction == Gdk.ScrollDirection.UP: 123 | self.emit("moved", Gdk.ScrollDirection.UP, step) 124 | elif direction == Gdk.ScrollDirection.DOWN: 125 | self.emit("moved", Gdk.ScrollDirection.DOWN, step) 126 | 127 | def do_moved(self, direction, step): 128 | """On 'moved' signal 129 | 130 | Args: 131 | direction (Gdk.ScrollDirection): UP or DOWN 132 | step (int): increment or decrement step size 133 | """ 134 | if direction == Gdk.ScrollDirection.UP: 135 | self.angle += step 136 | elif direction == Gdk.ScrollDirection.DOWN: 137 | self.angle -= step 138 | if self.angle > 360: 139 | self.angle -= 360 140 | elif self.angle < -360: 141 | self.angle += 360 142 | self.queue_draw() 143 | 144 | def do_draw(self, cr): 145 | """Draw Controller 146 | 147 | Args: 148 | cr: Cairo context 149 | """ 150 | scale = 2 151 | self.set_size_request(60 * scale, 60 * scale) 152 | width = self.get_allocation().width 153 | height = self.get_allocation().height 154 | # move to the center of the drawing area 155 | # (translate from the top left corner to w/2, h/2) 156 | cr.translate(width / 2, height / 2) 157 | cr.scale(scale, scale) 158 | # Circle 159 | cr.set_line_width(1) 160 | cr.set_source_rgba(0.1, 0.1, 0.1, 1.0) 161 | cr.arc(0, 0, 20, 0, math.radians(360)) 162 | cr.stroke() 163 | if App().midi.learning == self.text: 164 | cr.set_source_rgb(0.3, 0.2, 0.2) 165 | else: 166 | cr.set_source_rgba(0.2, 0.2, 0.2, 1.0) 167 | cr.arc(0, 0, 19, 0, math.radians(360)) 168 | cr.fill() 169 | # LED 170 | if self.led: 171 | cr.set_line_width(1) 172 | cr.set_source_rgba(0.8, 0.4, 0.4, 1.0) 173 | x1 = 27 * math.cos(math.radians(self.angle)) 174 | y1 = 27 * math.sin(math.radians(self.angle)) 175 | cr.arc(x1, y1, 3, math.radians(0), math.radians(360)) 176 | cr.fill() 177 | # Knob 178 | cr.set_line_width(2) 179 | cr.set_source_rgba(0.1, 0.1, 0.1, 1.0) 180 | x1 = 10 * math.cos(math.radians(self.angle)) 181 | y1 = 10 * math.sin(math.radians(self.angle)) 182 | x2 = 16 * math.cos(math.radians(self.angle)) 183 | y2 = 16 * math.sin(math.radians(self.angle)) 184 | cr.move_to(x1, y1) 185 | cr.line_to(x2, y2) 186 | cr.stroke() 187 | -------------------------------------------------------------------------------- /src/widgets/curve.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gtk 16 | from olc.define import App 17 | 18 | from .common import rounded_rectangle_fill 19 | 20 | 21 | class CurveWidget(Gtk.Button): 22 | """Curve widget""" 23 | 24 | __gtype_name__ = "CurveWidget" 25 | 26 | def __init__(self, curve: int): 27 | super().__init__() 28 | self.curve_nb = curve 29 | self.curve = App().lightshow.curves.get_curve(curve) 30 | 31 | self.connect("clicked", self.on_click) 32 | 33 | def on_click(self, _button) -> None: 34 | """Button clicked 35 | 36 | Raises: 37 | NotImplementedError: Must be implemented in subclass 38 | """ 39 | raise NotImplementedError 40 | 41 | def do_draw(self, cr): 42 | """Draw curve 43 | 44 | Args: 45 | cr: Cairo context 46 | """ 47 | self.set_size_request(80, 80) 48 | width = self.get_allocation().width 49 | height = self.get_allocation().height 50 | if isinstance(self.get_parent(), Gtk.FlowBoxChild): 51 | if self.get_parent().is_selected(): 52 | cr.set_source_rgba(0.6, 0.4, 0.1, 1.0) 53 | else: 54 | cr.set_source_rgba(0.3, 0.3, 0.3, 1.0) 55 | else: 56 | state = self.get_state_flags() 57 | if state & Gtk.StateFlags.ACTIVE: 58 | cr.set_source_rgba(0.5, 0.3, 0.0, 1.0) 59 | else: 60 | cr.set_source_rgba(0.3, 0.3, 0.3, 1.0) 61 | area = (0, width, 0, height) 62 | rounded_rectangle_fill(cr, area, 10) 63 | cr.set_line_width(2) 64 | cr.set_source_rgba(0.0, 0.0, 0.0, 1.0) 65 | cr.move_to(0, height - self.curve.values[0]) 66 | for x, y in self.curve.values.items(): 67 | cr.line_to((x / 255) * width, height - ((y / 255) * height)) 68 | cr.stroke() 69 | -------------------------------------------------------------------------------- /src/widgets/curve_point.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import math 16 | from dataclasses import dataclass 17 | 18 | from gi.repository import Gdk, GObject, Gtk 19 | from olc.define import App 20 | 21 | 22 | @dataclass 23 | class Point: 24 | """Just a point""" 25 | 26 | x = 0 27 | y = 0 28 | 29 | 30 | class CurvePointWidget(Gtk.DrawingArea): 31 | """Curve point widget""" 32 | 33 | __gtype_name__ = "CurvePointWidget" 34 | 35 | __gsignals__ = {"toggled": (GObject.SIGNAL_RUN_FIRST, None, ())} 36 | 37 | def __init__(self, *args, number=0, curve=None, **kwds): 38 | super().__init__(*args, **kwds) 39 | self.active = False 40 | self.number = number 41 | self.curve = curve 42 | evmask = Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON1_MOTION_MASK 43 | self.set_events(evmask) 44 | self.connect("button_press_event", self.button_pressed) 45 | self.connect("motion_notify_event", self.motion_notify) 46 | self.offset = Point() 47 | self.prev = Point() 48 | self.max = Point() 49 | 50 | def button_pressed(self, widget, event): 51 | """Button pressed 52 | 53 | Args: 54 | widget: Widget pressed 55 | event: Event with coordinates 56 | """ 57 | if event.button == 1: 58 | tab = App().tabs.tabs["curves"] 59 | for toggle in tab.curve_edition.points: 60 | toggle.set_active(False) 61 | toggle.queue_draw() 62 | self.set_active(True) 63 | parent = widget.get_parent() 64 | window = parent.get_window() 65 | self.offset.x, self.offset.y = window.get_root_origin() 66 | x, y = parent.translate_coordinates(self.get_toplevel(), 0, 0) 67 | self.offset.x += x 68 | self.offset.y += y 69 | self.offset.x += event.x 70 | self.offset.y += event.y 71 | self.max.x = parent.get_allocation().width - widget.get_allocation().width 72 | self.max.y = parent.get_allocation().height - widget.get_allocation().height 73 | # Update Label with point coordinates 74 | x = round(event.x_root - self.offset.x) 75 | y = round(event.y_root + 40 - self.offset.y) 76 | # 20 = grid offset, 4 = point radius 77 | x = max(min(x, self.max.x), 20 - 4) 78 | y = max(min(y, self.max.y), 20 - 4) 79 | edit_wgt_width = tab.curve_edition.edit_curve.width 80 | edit_wgt_height = tab.curve_edition.edit_curve.height 81 | x_curve = round(((x - 20 + 4) / (edit_wgt_width - 40)) * 255) 82 | y_curve = round( 83 | ((edit_wgt_height - y - 20 - 4) / (edit_wgt_height - 40)) * 255) 84 | tab.curve_edition.label.set_label(f"{x_curve}, {y_curve}") 85 | 86 | def motion_notify(self, widget, event): 87 | """Button moved 88 | 89 | Args: 90 | widget: Widget moved 91 | event: Event with coordinates 92 | """ 93 | x = round(event.x_root - self.offset.x) 94 | y = round(event.y_root + 40 - self.offset.y) 95 | # 20 = grid offset, 4 = point radius 96 | x = max(min(x, self.max.x), 20 - 4) 97 | y = max(min(y, self.max.y - 16), 20 - 4) 98 | if x != self.prev.x or y != self.prev.y: 99 | self.prev.x = x 100 | self.prev.y = y 101 | fixed = self.get_parent() 102 | tab = App().tabs.tabs["curves"] 103 | edit_wgt_width = tab.curve_edition.edit_curve.width 104 | edit_wgt_height = tab.curve_edition.edit_curve.height 105 | x_curve = round(((x - 20 + 4) / (edit_wgt_width - 40)) * 255) 106 | y_curve = round( 107 | ((edit_wgt_height - y - 20 - 4) / (edit_wgt_height - 40)) * 255) 108 | # First point 109 | if self.number == 0: 110 | tab.curve_edition.label.set_label(f"0, {y_curve}") 111 | self.curve.points[self.number] = (0, y_curve) 112 | fixed.move(widget, 16, y) 113 | # Last point 114 | elif self.number == len(self.curve.points) - 1: 115 | tab.curve_edition.label.set_label(f"255, {y_curve}") 116 | self.curve.points[self.number] = (255, y_curve) 117 | fixed.move(widget, 976, y) 118 | # Don't move before/after previous/next point 119 | elif (not x_curve <= self.curve.points[self.number - 1][0] 120 | and not x_curve >= self.curve.points[self.number + 1][0]): 121 | tab.curve_edition.label.set_label(f"{x_curve}, {y_curve}") 122 | if any(x_curve in point for point in self.curve.points): 123 | if self.curve.points[self.number][0] == x_curve: 124 | self.curve.points[self.number] = (x_curve, y_curve) 125 | fixed.move(widget, x, y) 126 | if not any(x_curve in point for point in self.curve.points): 127 | self.curve.points[self.number] = (x_curve, y_curve) 128 | fixed.move(widget, x, y) 129 | self.curve.populate_values() 130 | App().lightshow.set_modified() 131 | if App().tabs.tabs["patch_outputs"]: 132 | App().tabs.tabs["patch_outputs"].refresh() 133 | 134 | def get_active(self) -> bool: 135 | """Return activate status 136 | 137 | Returns: 138 | True or False 139 | """ 140 | if self.active: 141 | return True 142 | return False 143 | 144 | def set_active(self, active: bool): 145 | """Set activate status 146 | 147 | Args: 148 | active: True or False 149 | """ 150 | self.active = active 151 | self.queue_draw() 152 | 153 | def do_draw(self, cr): 154 | """Draw Curve Point Widget 155 | 156 | Args: 157 | cr: Cairo context 158 | """ 159 | self.set_size_request(8, 8) 160 | cr.set_line_width(1) 161 | if self.get_active(): 162 | cr.set_source_rgba(0.7, 0.5, 0.2, 1.0) 163 | else: 164 | cr.set_source_rgba(0.5, 0.3, 0.0, 1.0) 165 | cr.arc(4, 4, 4, 0, 2 * math.pi) 166 | cr.fill() 167 | -------------------------------------------------------------------------------- /src/widgets/edit_curve.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import cairo 16 | from gi.repository import Gdk, Gtk 17 | from olc.curve import InterpolateCurve, SegmentsCurve 18 | from olc.define import App 19 | 20 | 21 | class EditCurveWidget(Gtk.DrawingArea): 22 | """Curve edition widget""" 23 | 24 | __gtype_name__ = "EditCurveWidget" 25 | 26 | def __init__(self, curve: int): 27 | super().__init__() 28 | self.delta = 20 29 | self.width = 1000 30 | self.height = 300 31 | self.curve_nb = curve 32 | self.curve = App().lightshow.curves.get_curve(curve) 33 | self.set_size_request(self.width, self.height) 34 | 35 | self.offsetx = 0 36 | self.offsety = 0 37 | if self.curve.editable: 38 | self.add_events(Gdk.EventMask.POINTER_MOTION_MASK) 39 | self.connect("motion-notify-event", self.on_mouse_move) 40 | self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) 41 | self.connect("button-press-event", self.on_press) 42 | 43 | def on_press(self, _tgt, event): 44 | """Mouse button pressed 45 | 46 | Args: 47 | event: Gdk.event 48 | """ 49 | accel_mask = Gtk.accelerator_get_default_mod_mask() 50 | # Create new point with mouse click + Shift 51 | if (event.button == 1 52 | and event.state & accel_mask == Gdk.ModifierType.SHIFT_MASK 53 | and isinstance(self.curve, (SegmentsCurve, InterpolateCurve))): 54 | x_curve = round(((event.x - 20) / (self.width - 40)) * 255) 55 | y_curve = round(((self.height - event.y - 20) / (self.height - 40)) * 255) 56 | x_curve = max(min(x_curve, 255), 0) 57 | y_curve = max(min(y_curve, 255), 0) 58 | self.curve.add_point(x_curve, y_curve) 59 | self.queue_draw() 60 | tab = App().tabs.tabs["curves"] 61 | tab.curve_edition.points_curve() 62 | idx = self.curve.points.index((x_curve, y_curve)) 63 | App().tabs.tabs["curves"].curve_edition.points[idx].set_active(True) 64 | App().tabs.tabs["curves"].curve_edition.points[idx].queue_draw() 65 | App().lightshow.set_modified() 66 | if App().tabs.tabs["patch_outputs"]: 67 | App().tabs.tabs["patch_outputs"].refresh() 68 | 69 | def on_mouse_move(self, _widget, event: Gdk.Event) -> None: 70 | """Update pointer coordinates 71 | 72 | Args: 73 | event: Event with coordinates 74 | """ 75 | tab = App().tabs.tabs["curves"] 76 | x_curve = round(((event.x - 20) / (self.width - 40)) * 255) 77 | y_curve = round(((self.height - event.y - 20) / (self.height - 40)) * 255) 78 | x_curve = max(min(x_curve, 255), 0) 79 | y_curve = max(min(y_curve, 255), 0) 80 | tab.curve_edition.label.set_label(f"{x_curve}, {y_curve}") 81 | 82 | def do_draw(self, cr): 83 | """Draw Edit Curve Widget 84 | 85 | Args: 86 | cr: Cairo context 87 | """ 88 | width = self.get_allocation().width 89 | height = self.get_allocation().height 90 | cr.set_source_rgba(0.3, 0.3, 0.3, 1.0) 91 | cr.rectangle(0, 0, width, height) 92 | cr.fill() 93 | cr.set_line_width(3) 94 | cr.set_source_rgba(0.0, 0.0, 0.0, 1.0) 95 | cr.move_to(self.delta, self.delta) 96 | cr.line_to(self.delta, height - self.delta) 97 | cr.line_to(width - self.delta, height - self.delta) 98 | cr.stroke() 99 | cr.set_line_width(1) 100 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) 101 | cr.set_font_size(8) 102 | for x in range(0, 256, 10): 103 | cr.set_source_rgba(0.2, 0.2, 0.2, 1.0) 104 | cr.move_to( 105 | round((x / 255) * (width - (self.delta * 2))) + self.delta, self.delta) 106 | cr.line_to( 107 | round((x / 255) * (width - (self.delta * 2))) + self.delta, 108 | height - self.delta, 109 | ) 110 | cr.move_to( 111 | self.delta, 112 | round((x / 255) * (height - (self.delta * 2))) + self.delta + 5, 113 | ) 114 | cr.line_to( 115 | width - self.delta, 116 | round((x / 255) * (height - (self.delta * 2))) + self.delta + 5, 117 | ) 118 | cr.stroke() 119 | cr.set_source_rgba(0.0, 0.0, 0.0, 1.0) 120 | text = str(x) 121 | (_x, _y, t_width, t_height, _dx, _dy) = cr.text_extents(text) 122 | cr.move_to( 123 | round((x / 255) * (width - (self.delta * 2))) + self.delta - 124 | (t_width / 2), 125 | height - 8, 126 | ) 127 | cr.show_text(text) 128 | cr.move_to( 129 | 2, 130 | height - self.delta - round( 131 | (x / 255) * (height - (self.delta * 2))) + (t_height / 2), 132 | ) 133 | cr.show_text(text) 134 | cr.set_line_width(2) 135 | cr.set_source_rgba(0.5, 0.3, 0.0, 1.0) 136 | cr.move_to(self.delta, height - self.delta - self.curve.values[0]) 137 | for x, y in self.curve.values.items(): 138 | cr.line_to( 139 | (x / 255) * (width - (self.delta * 2)) + self.delta, 140 | height - self.delta - ((y / 255) * (height - (self.delta * 2))), 141 | ) 142 | cr.stroke() 143 | -------------------------------------------------------------------------------- /src/widgets/fader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gdk, GObject, Gtk 16 | from olc.define import App 17 | 18 | from .common import rounded_rectangle, rounded_rectangle_fill 19 | 20 | 21 | class FaderWidget(Gtk.Scale): 22 | """Fader widget, inherits from Gtk.scale""" 23 | 24 | __gtype_name__ = "FaderWidget" 25 | 26 | __gsignals__ = {"clicked": (GObject.SIGNAL_ACTION, None, ())} 27 | 28 | def __init__(self, *args, text="None", red=0.2, green=0.2, blue=0.2, **kwds): 29 | super().__init__(*args, **kwds) 30 | 31 | self.red = red 32 | self.green = green 33 | self.blue = blue 34 | self.led = True 35 | self.pressed = False 36 | 37 | self.text = text 38 | 39 | self.connect("button-press-event", self.on_press) 40 | self.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) 41 | self.connect("button-release-event", self.on_release) 42 | 43 | def on_press(self, _tgt, _ev): 44 | """Fader pressed""" 45 | self.pressed = True 46 | self.queue_draw() 47 | 48 | if self in (App().virtual_console.scale_a, App().virtual_console.scale_b): 49 | App().crossfade.manual = True 50 | 51 | def on_release(self, _tgt, _ev): 52 | """Fader released""" 53 | self.pressed = False 54 | self.queue_draw() 55 | self.emit("clicked") 56 | 57 | def do_draw(self, cr): 58 | """Draw Fader 59 | 60 | Args: 61 | cr: Cairo context 62 | """ 63 | allocation = self.get_allocation() 64 | width = allocation.width 65 | height = allocation.height 66 | radius = 10 67 | 68 | layout = self.get_layout() 69 | layout_h = layout.get_pixel_size().height if layout else 0 70 | 71 | # Draw vertical box 72 | cr.set_source_rgb(self.red, self.green, self.blue) 73 | area = ((width / 2) - 3, (width / 2) + 3, layout_h + 10, height - 10) 74 | rounded_rectangle_fill(cr, area, radius / 2) 75 | cr.set_source_rgb(0.1, 0.1, 0.1) 76 | rounded_rectangle(cr, area, radius / 2) 77 | 78 | # Cursor height 79 | value = self.get_value() 80 | 81 | if self.get_inverted(): 82 | h = height - (((height - layout_h - 10 - (20 / 2)) / 255) * value) 83 | else: 84 | h = (layout_h + 10 + (20 / 2) + (((height - layout_h - 10 - 85 | (20 / 2)) / 255) * value)) 86 | 87 | # Draw LED 88 | if self.led: 89 | cr.set_source_rgba(0.5, 0.3, 0.0, 1.0) 90 | area = ((width / 2) - 2, (width / 2) + 2, h - 10, height - 10) 91 | rounded_rectangle_fill(cr, area, radius / 2) 92 | 93 | # Draw Cursor 94 | area = ((width / 2) - 19, (width / 2) + 19, h - 20, h) 95 | 96 | if App().midi.learning == self.text: 97 | if self.pressed: 98 | cr.set_source_rgb(0.2, 0.1, 0.1) 99 | else: 100 | cr.set_source_rgb(0.3, 0.2, 0.2) 101 | elif self.pressed: 102 | cr.set_source_rgb(0.5, 0.3, 0.0) 103 | else: 104 | cr.set_source_rgb(0.2, 0.2, 0.2) 105 | rounded_rectangle_fill(cr, area, radius) 106 | cr.set_source_rgb(0.1, 0.1, 0.1) 107 | rounded_rectangle(cr, area, radius) 108 | -------------------------------------------------------------------------------- /src/widgets/flash.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import cairo 16 | from gi.repository import Gdk, GObject, Gtk 17 | from olc.define import App 18 | 19 | from .common import rounded_rectangle, rounded_rectangle_fill 20 | 21 | 22 | class FlashWidget(Gtk.Widget): 23 | """Flash widget""" 24 | 25 | __gtype_name__ = "FlashWidget" 26 | 27 | __gsignals__ = {"clicked": (GObject.SIGNAL_ACTION, None, ())} 28 | 29 | def __init__(self, label="", text="None"): 30 | Gtk.Widget.__init__(self) 31 | 32 | self.width = 40 33 | self.height = 40 34 | self.radius = 10 35 | self.font_size = 8 36 | 37 | self.pressed = False 38 | self.label = label 39 | self.text = text 40 | 41 | self.set_size_request(self.width, self.height) 42 | 43 | self.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) 44 | 45 | self.connect("button-press-event", self.on_press) 46 | self.connect("button-release-event", self.on_release) 47 | 48 | def on_press(self, _tgt, _ev): 49 | """Flash button pressed""" 50 | self.pressed = True 51 | self.queue_draw() 52 | 53 | def on_release(self, _tgt, _ev): 54 | """Flash button released""" 55 | self.pressed = False 56 | self.queue_draw() 57 | self.emit("clicked") 58 | 59 | def do_draw(self, cr): 60 | """Draw Flash button 61 | 62 | Args: 63 | cr: Cairo context 64 | """ 65 | # Draw rounded box 66 | if self.text == "None": 67 | cr.set_source_rgb(0.4, 0.4, 0.4) 68 | elif self.pressed: 69 | if App().midi.learning == self.text: 70 | cr.set_source_rgb(0.2, 0.1, 0.1) 71 | else: 72 | cr.set_source_rgb(0.5, 0.3, 0.0) 73 | elif App().midi.learning == self.text: 74 | cr.set_source_rgb(0.3, 0.2, 0.2) 75 | else: 76 | cr.set_source_rgb(0.2, 0.2, 0.2) 77 | area = (1, self.width - 2, 1, self.height - 2) 78 | rounded_rectangle_fill(cr, area, self.radius) 79 | cr.set_source_rgb(0.1, 0.1, 0.1) 80 | rounded_rectangle(cr, area, self.radius) 81 | # Draw Text on 2 lines 82 | if self.text == "None": 83 | cr.set_source_rgb(0.5, 0.5, 0.5) 84 | else: 85 | cr.set_source_rgb(0.8, 0.8, 0.8) 86 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) 87 | cr.set_font_size(self.font_size) 88 | # First line 89 | (_x, _y, w, _h, _dx, _dy) = cr.text_extents(self.label[:6]) 90 | cr.move_to(self.width / 2 - w / 2, self.height / 3) 91 | cr.show_text(self.label[:6]) 92 | # Second line 93 | (_x, _y, w, _h, _dx, _dy) = cr.text_extents(self.label[6:12]) 94 | cr.move_to(self.width / 2 - w / 2, (self.height / 3) * 2) 95 | cr.show_text(self.label[6:12]) 96 | 97 | def do_realize(self): 98 | """Realize widget""" 99 | allocation = self.get_allocation() 100 | attr = Gdk.WindowAttr() 101 | attr.window_type = Gdk.WindowType.CHILD 102 | attr.x = allocation.x 103 | attr.y = allocation.y 104 | attr.width = allocation.width 105 | attr.height = allocation.height 106 | attr.visual = self.get_visual() 107 | attr.event_mask = (self.get_events() 108 | | Gdk.EventMask.EXPOSURE_MASK 109 | | Gdk.EventMask.BUTTON_PRESS_MASK 110 | | Gdk.EventMask.TOUCH_MASK) 111 | wat = Gdk.WindowAttributesType 112 | mask = wat.X | wat.Y | wat.VISUAL 113 | 114 | window = Gdk.Window(self.get_parent_window(), attr, mask) 115 | self.set_window(window) 116 | self.register_window(window) 117 | 118 | self.set_realized(True) 119 | window.set_background_pattern(None) 120 | -------------------------------------------------------------------------------- /src/widgets/go.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import cairo 16 | from gi.repository import Gdk, GObject, Gtk 17 | from olc.define import App 18 | 19 | from .common import rounded_rectangle, rounded_rectangle_fill 20 | 21 | 22 | class GoWidget(Gtk.Widget): 23 | """Go button widget""" 24 | 25 | __gtype_name__ = "GoWidget" 26 | 27 | __gsignals__ = {"clicked": (GObject.SIGNAL_RUN_FIRST, None, ())} 28 | 29 | def __init__(self, *args, **kwds): 30 | super().__init__(*args, **kwds) 31 | 32 | self.width = 100 33 | self.height = 50 34 | self.radius = 10 35 | 36 | self.pressed = False 37 | 38 | self.set_size_request(self.width, self.height) 39 | 40 | self.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) 41 | 42 | self.connect("button-press-event", self.on_press) 43 | self.connect("button-release-event", self.on_release) 44 | 45 | def on_press(self, _tgt, _ev): 46 | """Go pressed""" 47 | App().midi.messages.notes.send("go", 127) 48 | self.pressed = True 49 | self.queue_draw() 50 | 51 | def on_release(self, _tgt, _ev): 52 | """Go released""" 53 | App().midi.messages.notes.send("go", 0) 54 | self.pressed = False 55 | self.queue_draw() 56 | self.emit("clicked") 57 | 58 | def do_draw(self, cr): 59 | """Draw Go button 60 | 61 | Args: 62 | cr: Cairo context 63 | """ 64 | # Draw rounded box 65 | if self.pressed: 66 | if App().midi.learning == "go": 67 | cr.set_source_rgb(0.2, 0.1, 0.1) 68 | else: 69 | cr.set_source_rgb(0.5, 0.3, 0.0) 70 | elif App().midi.learning == "go": 71 | cr.set_source_rgb(0.3, 0.2, 0.2) 72 | else: 73 | cr.set_source_rgb(0.2, 0.2, 0.2) 74 | area = (1, self.width - 2, 1, self.height - 2) 75 | rounded_rectangle_fill(cr, area, self.radius) 76 | cr.set_source_rgb(0.1, 0.1, 0.1) 77 | rounded_rectangle(cr, area, self.radius) 78 | # Draw Go 79 | cr.set_source_rgb(0.8, 0.8, 0.8) 80 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) 81 | cr.set_font_size(10) 82 | (_x, _y, w, h, _dx, _dy) = cr.text_extents("Go") 83 | cr.move_to(self.width / 2 - w / 2, 84 | self.height / 2 - (h - (self.radius * 2)) / 2) 85 | cr.show_text("Go") 86 | 87 | def do_realize(self): 88 | """Realize widget""" 89 | allocation = self.get_allocation() 90 | attr = Gdk.WindowAttr() 91 | attr.window_type = Gdk.WindowType.CHILD 92 | attr.x = allocation.x 93 | attr.y = allocation.y 94 | attr.width = allocation.width 95 | attr.height = allocation.height 96 | attr.visual = self.get_visual() 97 | attr.event_mask = (self.get_events() 98 | | Gdk.EventMask.EXPOSURE_MASK 99 | | Gdk.EventMask.BUTTON_PRESS_MASK 100 | | Gdk.EventMask.TOUCH_MASK) 101 | wat = Gdk.WindowAttributesType 102 | mask = wat.X | wat.Y | wat.VISUAL 103 | 104 | window = Gdk.Window(self.get_parent_window(), attr, mask) 105 | self.set_window(window) 106 | self.register_window(window) 107 | 108 | self.set_realized(True) 109 | window.set_background_pattern(None) 110 | -------------------------------------------------------------------------------- /src/widgets/group.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import cairo 16 | from gi.repository import Gdk, Gtk 17 | from olc.define import App 18 | 19 | from .common import rounded_rectangle_fill 20 | 21 | 22 | class GroupWidget(Gtk.Widget): 23 | """Group widget""" 24 | 25 | __gtype_name__ = "GroupWidget" 26 | 27 | def __init__(self, number, name): 28 | self.number = number 29 | self.name = name 30 | 31 | Gtk.Widget.__init__(self) 32 | self.set_size_request(80, 80) 33 | self.connect("button-press-event", self.on_click) 34 | self.connect("touch-event", self.on_click) 35 | 36 | self.popover = Gtk.Popover() 37 | vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 38 | entry = Gtk.Entry() 39 | entry.set_has_frame(False) 40 | entry.set_text(name) 41 | entry.connect("activate", self.on_edit) 42 | vbox.pack_start(entry, False, True, 10) 43 | vbox.show_all() 44 | self.popover.add(vbox) 45 | self.popover.set_relative_to(self) 46 | self.popover.set_position(Gtk.PositionType.BOTTOM) 47 | 48 | def on_edit(self, widget: Gtk.Entry) -> None: 49 | """Edit Group text 50 | 51 | Args: 52 | widget: Entry used 53 | """ 54 | # Update widget text 55 | text = widget.get_text() 56 | self.name = text 57 | self.queue_draw() 58 | # Update group text 59 | flowboxchild = self.get_parent() 60 | index = flowboxchild.get_index() 61 | group = App().lightshow.groups[index] 62 | group.text = text 63 | fader_bank = App().lightshow.fader_bank 64 | # Update Fader text 65 | for page, faders in fader_bank.faders.items(): 66 | for fader in faders.values(): 67 | if fader.contents is group: 68 | fader.text = text 69 | # Update Virtual Console 70 | if App().virtual_console and page == fader_bank.active_page: 71 | App().virtual_console.flashes[fader.index - 1].label = text 72 | App().midi.messages.lcd.show_faders() 73 | self.popover.popdown() 74 | App().lightshow.set_modified() 75 | 76 | def on_click(self, _tgt, _ev): 77 | """Group clicked""" 78 | child = self.get_parent() 79 | if not child.is_selected(): 80 | App().tabs.tabs["groups"].flowbox.unselect_all() 81 | App().tabs.tabs["groups"].flowbox.select_child(child) 82 | App().tabs.tabs["groups"].last_group_selected = str(child.get_index()) 83 | App().tabs.tabs["groups"].channels_view.update() 84 | else: 85 | self.popover.popup() 86 | 87 | def do_draw(self, cr): 88 | """Draw Group widget 89 | 90 | Args: 91 | cr: Cairo context 92 | """ 93 | allocation = self.get_allocation() 94 | 95 | # paint background 96 | bg_color = self.get_style_context().get_background_color(Gtk.StateFlags.NORMAL) 97 | cr.set_source_rgba(*list(bg_color)) 98 | cr.rectangle(0, 0, allocation.width, allocation.height) 99 | cr.fill() 100 | 101 | # draw rectangle 102 | if self.get_parent().is_selected(): 103 | cr.set_source_rgb(0.6, 0.4, 0.1) 104 | else: 105 | cr.set_source_rgb(0.3, 0.3, 0.3) 106 | area = (0, allocation.width, 0, allocation.height) 107 | rounded_rectangle_fill(cr, area, 10) 108 | 109 | # draw group number 110 | cr.set_source_rgb(0.5, 0.5, 0.9) 111 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) 112 | cr.set_font_size(12) 113 | cr.move_to(50, 15) 114 | txt = str(int(self.number)) if self.number.is_integer() else str(self.number) 115 | cr.show_text(txt) 116 | # draw group name 117 | cr.set_source_rgb(0.9, 0.9, 0.9) 118 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.NORMAL) 119 | cr.move_to(8, 32) 120 | if len(self.name) > 10: 121 | cr.show_text(self.name[:10]) 122 | cr.move_to(8, 48) 123 | cr.show_text(self.name[10:]) 124 | else: 125 | cr.show_text(self.name) 126 | 127 | def do_realize(self): 128 | """Realize widget""" 129 | allocation = self.get_allocation() 130 | attr = Gdk.WindowAttr() 131 | attr.window_type = Gdk.WindowType.CHILD 132 | attr.x = allocation.x 133 | attr.y = allocation.y 134 | attr.width = allocation.width 135 | attr.height = allocation.height 136 | attr.visual = self.get_visual() 137 | attr.event_mask = (self.get_events() 138 | | Gdk.EventMask.EXPOSURE_MASK 139 | | Gdk.EventMask.BUTTON_PRESS_MASK 140 | | Gdk.EventMask.TOUCH_MASK) 141 | wat = Gdk.WindowAttributesType 142 | mask = wat.X | wat.Y | wat.VISUAL 143 | 144 | window = Gdk.Window(self.get_parent_window(), attr, mask) 145 | self.set_window(window) 146 | self.register_window(window) 147 | 148 | self.set_realized(True) 149 | window.set_background_pattern(None) 150 | -------------------------------------------------------------------------------- /src/widgets/knob.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import math 16 | 17 | from gi.repository import Gdk, GObject, Gtk 18 | from olc.define import App 19 | 20 | 21 | class KnobWidget(Gtk.DrawingArea): 22 | """Knob widget, inherits from Gtk.DrawingArea""" 23 | 24 | __gtype_name__ = "KnobWidget" 25 | 26 | __gsignals__ = { 27 | "clicked": (GObject.SignalFlags.ACTION, None, ()), 28 | "changed": (GObject.SignalFlags.ACTION, None, ()), 29 | } 30 | 31 | def __init__(self, text="None"): 32 | Gtk.DrawingArea.__init__(self) 33 | 34 | self.value = 0 35 | self.text = text 36 | 37 | # Mouse position when button clicked 38 | self.x1 = 0 39 | self.y1 = 0 40 | self.old_angle = 0 41 | self.old_value = 0 42 | 43 | self.add_events(Gdk.EventMask.SCROLL_MASK) 44 | self.connect("scroll-event", self.on_scroll) 45 | self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) 46 | self.connect("button-press-event", self.on_press) 47 | self.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) 48 | self.connect("button-release-event", self.on_release) 49 | self.add_events(Gdk.EventMask.BUTTON1_MOTION_MASK) 50 | self.connect("motion-notify-event", self.on_motion) 51 | 52 | def on_press(self, _tgt, event): 53 | """Mouse button pressed 54 | 55 | Args: 56 | event: Gdk.Event 57 | """ 58 | self.x1 = event.x 59 | self.y1 = event.y 60 | self.old_value = self.value 61 | 62 | def on_release(self, _tgt, _ev): 63 | """Mouse button released""" 64 | self.old_angle = 0 65 | self.emit("clicked") 66 | 67 | def on_motion(self, _tgt, event): 68 | """Track mouse to rotate knob 69 | 70 | Args: 71 | event: Gdk.Event 72 | """ 73 | x0 = self.get_allocation().width / 2 74 | y0 = self.get_allocation().height / 2 75 | x2 = event.x 76 | y2 = event.y 77 | y = (self.x1 - x0) * (y2 - y0) - (self.y1 - y0) * (x2 - x0) 78 | x = (self.x1 - x0) * (x2 - x0) + (self.y1 - y0) * (y2 - y0) 79 | angle = math.atan2(y, x) 80 | if angle > math.pi / 2: 81 | self.old_angle = self.old_angle + math.pi / 2 82 | angle = angle - math.pi / 2 83 | self.x1 = x2 84 | self.y1 = y2 85 | elif angle < -math.pi / 2: 86 | self.old_angle = self.old_angle - math.pi / 2 87 | angle = angle + math.pi / 2 88 | self.x1 = x2 89 | self.y1 = y2 90 | self.value = self.old_value + ((self.old_angle + angle) * (180 * 255) / 91 | (300 * math.pi)) 92 | if self.value < 0: 93 | self.value = 0 94 | elif self.value > 255: 95 | self.value = 255 96 | self.emit("changed") 97 | self.queue_draw() 98 | 99 | def get_value(self): 100 | """ 101 | Returns: 102 | value (0-255) 103 | """ 104 | return self.value 105 | 106 | def on_scroll(self, _widget, event): 107 | """On mouse wheel event 108 | 109 | Args: 110 | event: Gdk.Event 111 | """ 112 | accel_mask = Gtk.accelerator_get_default_mod_mask() 113 | step = 1 if event.state & accel_mask == Gdk.ModifierType.SHIFT_MASK else 10 114 | (scroll, direction) = event.get_scroll_direction() 115 | if scroll and direction == Gdk.ScrollDirection.UP: 116 | self.value += step 117 | if scroll and direction == Gdk.ScrollDirection.DOWN: 118 | self.value -= step 119 | if self.value < 0: 120 | self.value = 0 121 | elif self.value > 255: 122 | self.value = 255 123 | self.emit("changed") 124 | self.queue_draw() 125 | 126 | def do_draw(self, cr): 127 | """Draw Knob 128 | 129 | Args: 130 | cr: Cairo context 131 | """ 132 | scale = 1.5 133 | self.set_size_request(34 * scale, 34 * scale) 134 | width = self.get_allocation().width 135 | height = self.get_allocation().height 136 | 137 | # move to the center of the drawing area 138 | # (translate from the top left corner to w/2, h/2) 139 | cr.translate(width / 2, height / 2) 140 | cr.rotate(math.radians(-220)) 141 | cr.scale(scale, scale) 142 | cr.set_line_width(2) 143 | cr.set_source_rgba(0.1, 0.1, 0.1, 1.0) 144 | cr.arc(0, 0, 10, 0, math.radians(360)) 145 | cr.stroke() 146 | if App().midi.learning == self.text: 147 | cr.set_source_rgb(0.3, 0.2, 0.2) 148 | else: 149 | cr.set_source_rgba(0.2, 0.2, 0.2, 1.0) 150 | cr.arc(0, 0, 10, 0, math.radians(360)) 151 | cr.fill() 152 | 153 | angle = (self.get_value() / 255) * 260 154 | # LED 155 | cr.set_line_width(2) 156 | cr.set_source_rgba(0.25, 0.25, 0.25, 1.0) 157 | cr.arc(0, 0, 15, math.radians(0), math.radians(260)) 158 | cr.stroke() 159 | cr.set_line_width(3) 160 | cr.set_source_rgba(0.5, 0.3, 0.0, 1.0) 161 | cr.arc(0, 0, 15, math.radians(0), math.radians(angle)) 162 | cr.stroke() 163 | # Knob 164 | cr.set_line_width(2) 165 | cr.set_source_rgba(0.1, 0.1, 0.1, 1.0) 166 | x1 = 2 * math.cos(math.radians(angle)) 167 | y1 = 2 * math.sin(math.radians(angle)) 168 | x2 = 8 * math.cos(math.radians(angle)) 169 | y2 = 8 * math.sin(math.radians(angle)) 170 | cr.move_to(x1, y1) 171 | cr.line_to(x2, y2) 172 | cr.stroke() 173 | -------------------------------------------------------------------------------- /src/widgets/main_fader.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import cairo 16 | from gi.repository import Gdk, Gtk 17 | from olc.define import App 18 | 19 | from .common import rounded_rectangle 20 | 21 | 22 | class MainFaderWidget(Gtk.Widget): 23 | """Main Fader widget""" 24 | 25 | __gtype_name__ = "MainFaderWidget" 26 | 27 | def __init__(self): 28 | Gtk.Widget.__init__(self) 29 | 30 | self.width = 100 31 | self.height = 30 32 | self.radius = 10 33 | self.label = "" 34 | 35 | self.set_size_request(self.width, self.height) 36 | 37 | def do_draw(self, cr): 38 | """Draw Main Fader widget 39 | 40 | Args: 41 | cr: Cairo context 42 | """ 43 | if App().backend.dmx.main_fader.value != 1: 44 | # Draw rounded box 45 | cr.set_source_rgb(0.7, 0.2, 0.2) 46 | area = (1, self.width - 2, 1, self.height - 2) 47 | rounded_rectangle(cr, area, self.radius) 48 | # Draw Text 49 | self.label = (f"Main Fader " 50 | f"{round(App().backend.dmx.main_fader.value * 100)}%") 51 | cr.set_source_rgb(0.8, 0.3, 0.3) 52 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) 53 | cr.set_font_size(11) 54 | (_x, _y, w, h, _dx, _dy) = cr.text_extents(self.label) 55 | cr.move_to(self.width / 2 - w / 2, 56 | self.height / 2 - (h - (self.radius * 2)) / 2) 57 | cr.show_text(self.label) 58 | 59 | def do_realize(self): 60 | """Realize widget""" 61 | allocation = self.get_allocation() 62 | attr = Gdk.WindowAttr() 63 | attr.window_type = Gdk.WindowType.CHILD 64 | attr.x = allocation.x 65 | attr.y = allocation.y 66 | attr.width = allocation.width 67 | attr.height = allocation.height 68 | attr.visual = self.get_visual() 69 | attr.event_mask = (self.get_events() 70 | | Gdk.EventMask.EXPOSURE_MASK 71 | | Gdk.EventMask.BUTTON_PRESS_MASK 72 | | Gdk.EventMask.TOUCH_MASK) 73 | wat = Gdk.WindowAttributesType 74 | mask = wat.X | wat.Y | wat.VISUAL 75 | 76 | window = Gdk.Window(self.get_parent_window(), attr, mask) 77 | self.set_window(window) 78 | self.register_window(window) 79 | 80 | self.set_realized(True) 81 | window.set_background_pattern(None) 82 | -------------------------------------------------------------------------------- /src/widgets/pause.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | import cairo 16 | from gi.repository import Gtk 17 | from olc.define import App 18 | 19 | from .common import rounded_rectangle, rounded_rectangle_fill 20 | 21 | 22 | class PauseWidget(Gtk.Button): 23 | """Pause button widget""" 24 | 25 | __gtype_name__ = "PauseWidget" 26 | 27 | def __init__(self, label="", text="None"): 28 | Gtk.Button.__init__(self) 29 | 30 | self.width = 50 31 | self.height = 50 32 | self.radius = 10 33 | self.font_size = 10 34 | 35 | self.pressed = False 36 | self.label = label 37 | self.text = text 38 | 39 | self.set_size_request(self.width, self.height) 40 | 41 | self.connect("button-press-event", self.on_press) 42 | self.connect("button-release-event", self.on_release) 43 | 44 | def on_press(self, _tgt, _ev): 45 | """Button pressed""" 46 | self.pressed = True 47 | App().midi.messages.notes.send(self.text, 127) 48 | 49 | def on_release(self, _tgt, _ev): 50 | """Button released""" 51 | # channel, note = App().midi.messages.notes.notes[self.text] 52 | if App().lightshow.main_playback.on_go: 53 | if App().lightshow.main_playback.thread and App( 54 | ).lightshow.main_playback.thread.pause.is_set(): 55 | self.pressed = True 56 | App().midi.messages.notes.send(self.text, 127) 57 | elif App().lightshow.main_playback.thread: 58 | self.pressed = False 59 | App().midi.messages.notes.send(self.text, 0) 60 | else: 61 | self.pressed = False 62 | App().midi.messages.notes.send(self.text, 0) 63 | 64 | def do_draw(self, cr): 65 | """Draw Pause button 66 | 67 | Args: 68 | cr: Cairo context 69 | """ 70 | if self.text == "None": 71 | cr.set_source_rgb(0.4, 0.4, 0.4) 72 | elif self.pressed: 73 | if App().midi.learning == self.text: 74 | cr.set_source_rgb(0.2, 0.1, 0.1) 75 | else: 76 | cr.set_source_rgb(0.5, 0.3, 0.0) 77 | elif App().midi.learning == self.text: 78 | cr.set_source_rgb(0.3, 0.2, 0.2) 79 | else: 80 | cr.set_source_rgb(0.2, 0.2, 0.2) 81 | area = (1, self.width - 2, 1, self.height - 2) 82 | rounded_rectangle_fill(cr, area, self.radius) 83 | cr.set_source_rgb(0.1, 0.1, 0.1) 84 | rounded_rectangle(cr, area, self.radius) 85 | # Draw Text 86 | if self.text == "None": 87 | cr.set_source_rgb(0.5, 0.5, 0.5) 88 | else: 89 | cr.set_source_rgb(0.8, 0.8, 0.8) 90 | cr.select_font_face("Monaco", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) 91 | cr.set_font_size(self.font_size) 92 | (_x, _y, w, h, _dx, _dy) = cr.text_extents(self.label) 93 | cr.move_to(self.width / 2 - w / 2, 94 | self.height / 2 - (h - (self.radius * 2)) / 2) 95 | cr.show_text(self.label) 96 | -------------------------------------------------------------------------------- /src/widgets/toggle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gtk 16 | from olc.define import App 17 | 18 | from .common import rounded_rectangle, rounded_rectangle_fill 19 | 20 | 21 | class ToggleWidget(Gtk.ToggleButton): 22 | """Toggle button widget""" 23 | 24 | __gtype_name__ = "ToggleWidget" 25 | 26 | def __init__(self, text="None"): 27 | Gtk.ToggleButton.__init__(self) 28 | 29 | self.width = 50 30 | self.height = 50 31 | self.radius = 5 32 | self.text = text 33 | 34 | def do_draw(self, cr): 35 | """Draw Toggle button 36 | 37 | Args: 38 | cr: Cairo context 39 | """ 40 | self.set_size_request(self.width, self.height) 41 | # Button 42 | area = (10, self.width - 10, 10, self.height - 10) 43 | if App().midi.learning == self.text: 44 | cr.set_source_rgb(0.3, 0.2, 0.2) 45 | elif self.get_active(): 46 | cr.set_source_rgb(0.5, 0.3, 0.0) 47 | App().midi.messages.notes.send(self.text, 127) 48 | else: 49 | cr.set_source_rgb(0.2, 0.2, 0.2) 50 | App().midi.messages.notes.send(self.text, 0) 51 | rounded_rectangle_fill(cr, area, self.radius) 52 | cr.set_source_rgb(0.1, 0.1, 0.1) 53 | rounded_rectangle(cr, area, self.radius) 54 | -------------------------------------------------------------------------------- /src/window_channels.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from gi.repository import Gdk, Gtk 16 | from olc.define import UNIVERSES, App 17 | from olc.widgets.channels_view import VIEW_MODES, ChannelsView 18 | 19 | 20 | class LiveView(Gtk.Notebook): 21 | """Live Channels View""" 22 | 23 | def __init__(self): 24 | Gtk.Notebook.__init__(self) 25 | self.set_group_name("olc") 26 | 27 | self.channels_view = LiveChannelsView() 28 | 29 | self.append_page(self.channels_view, Gtk.Label("Channels")) 30 | self.set_tab_reorderable(self.channels_view, True) 31 | self.set_tab_detachable(self.channels_view, True) 32 | 33 | self.connect("key_press_event", self.on_key_press_event) 34 | 35 | def update_channel_widget(self, channel: int, next_level: int) -> None: 36 | """Update display of channel widget 37 | 38 | Args: 39 | channel: Index of channel (from 1 to MAX_CHANNELS) 40 | next_level: Channel next level (from 0 to 255) 41 | """ 42 | widget = self.channels_view.get_channel_widget(channel) 43 | channel -= 1 44 | widget.color_level = {"red": 0.9, "green": 0.9, "blue": 0.9} 45 | level = App().backend.dmx.levels["sequence"][channel] 46 | if not App().lightshow.main_playback.on_go and App( 47 | ).backend.dmx.levels["user"][channel] != -1: 48 | level = App().backend.dmx.levels["user"][channel] 49 | if App().backend.dmx.levels["faders"][channel] > level: 50 | level = App().backend.dmx.levels["faders"][channel] 51 | widget.color_level = {"red": 0.4, "green": 0.7, "blue": 0.4} 52 | if App().lightshow.independents.dmx[channel] > level: 53 | level = App().lightshow.independents.dmx[channel] 54 | widget.color_level = {"red": 0.4, "green": 0.4, "blue": 0.7} 55 | widget.level = level 56 | widget.next_level = next_level 57 | widget.queue_draw() 58 | 59 | def on_key_press_event(self, widget, event): 60 | """On key press event 61 | 62 | Args: 63 | widget: Gtk Widget 64 | event: Gdk.EventKey 65 | 66 | Returns: 67 | function() to handle keys pressed 68 | """ 69 | keyname = Gdk.keyval_name(event.keyval) 70 | if keyname == "Tab": 71 | return App().window.toggle_focus() 72 | if keyname == "ISO_Left_Tab": 73 | return App().window.move_tab() 74 | # Find open page in notebook to send keyboard events 75 | page = self.get_current_page() 76 | child = self.get_nth_page(page) 77 | if child in App().tabs.tabs.values(): 78 | return child.on_key_press_event(widget, event) 79 | return App().window.on_key_press_event(widget, event) 80 | 81 | 82 | class LiveChannelsView(ChannelsView): 83 | """Channels View""" 84 | 85 | def __init__(self): 86 | super().__init__() 87 | 88 | def filter_channels(self, child: Gtk.FlowBoxChild, _user_data) -> bool: 89 | """Filter channels to display 90 | 91 | Args: 92 | child: Parent of Channel Widget 93 | 94 | Returns: 95 | True or False 96 | """ 97 | channel_widget = child.get_child() 98 | channel = child.get_index() + 1 99 | channel_widget.next_level = self.get_next_level(channel, channel_widget) 100 | if self.view_mode == VIEW_MODES["Active"]: 101 | visible = bool(channel_widget.level or channel_widget.next_level 102 | or child.is_selected()) 103 | child.set_visible(visible) 104 | return visible 105 | 106 | if self.view_mode == VIEW_MODES["Patched"]: 107 | patched = App().lightshow.patch.is_patched(channel) 108 | child.set_visible(patched) 109 | return patched 110 | if not App().lightshow.patch.is_patched(channel): 111 | channel_widget.level = 0 112 | child.set_visible(True) 113 | return True 114 | 115 | def get_next_level(self, channel: int, channel_widget) -> int: 116 | """Get Channel next level 117 | 118 | Args: 119 | channel: Channel number (1 - MAX_CHANNELS) 120 | channel_widget: Channel widget 121 | 122 | Returns: 123 | Channel next level (0 - 255) 124 | """ 125 | position = App().lightshow.main_playback.position 126 | if (App().lightshow.main_playback.last > 1 127 | and position < App().lightshow.main_playback.last - 1 128 | and App().lightshow.main_playback.last <= len( 129 | App().lightshow.main_playback.steps)): 130 | next_level = App().lightshow.main_playback.steps[position + 131 | 1].cue.channels.get( 132 | channel, 0) 133 | elif App().lightshow.main_playback.last: 134 | next_level = App().lightshow.main_playback.steps[0].cue.channels.get( 135 | channel, 0) 136 | else: 137 | next_level = channel_widget.level 138 | return next_level 139 | 140 | def set_channel_level(self, channel: int, level: int) -> None: 141 | """Set channel level 142 | 143 | Args: 144 | channel: channel number (1 - MAX_CHANNELS) 145 | level: DMX level (0 - 255) 146 | """ 147 | App().backend.dmx.levels["user"][channel - 1] = level 148 | App().lightshow.main_playback.update_channels() 149 | App().backend.dmx.set_levels({channel}) 150 | App().window.live_view.update_channel_widget(channel, level) 151 | 152 | def wheel_level(self, step: int, direction: Gdk.ScrollDirection) -> None: 153 | """Change patched channels level with a wheel 154 | 155 | Args: 156 | step: Step level 157 | direction: Up or Down 158 | """ 159 | level = None 160 | channels = self.get_selected_channels() 161 | for channel in channels: 162 | if not App().lightshow.patch.is_patched(channel): 163 | continue 164 | for output in App().lightshow.patch.channels[channel]: 165 | out = output[0] 166 | univ = output[1] 167 | index = UNIVERSES.index(univ) 168 | level = App().backend.dmx.frame[index][out - 1] 169 | if direction == Gdk.ScrollDirection.UP: 170 | App().backend.dmx.levels["user"][channel - 1] = min( 171 | level + step, 255) 172 | elif direction == Gdk.ScrollDirection.DOWN: 173 | App().backend.dmx.levels["user"][channel - 1] = max(level - step, 0) 174 | next_level = App().lightshow.main_playback.get_next_channel_level( 175 | channel, level) 176 | App().window.live_view.update_channel_widget(channel, next_level) 177 | App().backend.dmx.set_levels(set(channels)) 178 | -------------------------------------------------------------------------------- /src/zoom.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Open Lighting Console 3 | # Copyright (c) 2015-2024 Mika Cousin 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program. If not, see . 15 | from olc.define import App 16 | from olc.patch_outputs import PatchOutputsTab 17 | from olc.widgets.channels_view import ChannelsView 18 | 19 | 20 | def zoom(direction: str) -> None: 21 | """Zoom in/out widgets 22 | FlowBox child needs a 'scale' attribute 23 | 24 | Args: 25 | direction: "in" or "out" 26 | """ 27 | tab = App().window.get_active_tab() 28 | children = tab.get_children() 29 | 30 | view = None 31 | if isinstance(tab, (ChannelsView, PatchOutputsTab)): 32 | view = tab 33 | else: 34 | for child in children: 35 | if isinstance(child, ChannelsView): 36 | view = child 37 | if view: 38 | for flowboxchild in view.flowbox.get_children(): 39 | child = flowboxchild.get_child() 40 | if direction == "in" and child.scale < 2: 41 | child.scale += 0.01 42 | elif direction == "out" and child.scale >= 1: 43 | child.scale -= 0.01 44 | flowboxchild.queue_draw() 45 | -------------------------------------------------------------------------------- /test/test_curve.py: -------------------------------------------------------------------------------- 1 | from curve import ( 2 | LinearCurve, 3 | SquareRootCurve, 4 | LimitCurve, 5 | SegmentsCurve, 6 | InterpolateCurve, 7 | ) 8 | 9 | 10 | def test_get_level(): 11 | curve = LinearCurve() 12 | assert curve.name == "Linear" 13 | for x in range(256): 14 | assert curve.get_level(x) == x 15 | 16 | 17 | def test_get_level_square_root(): 18 | curve = SquareRootCurve() 19 | assert curve.name == "Square root" 20 | assert curve.get_level(0) == 0 21 | assert curve.get_level(25) == 80 22 | assert curve.get_level(255) == 255 23 | 24 | 25 | def test_get_level_limit(): 26 | curve = LimitCurve(limit=127) 27 | assert curve.name == "Limit" 28 | assert curve.limit == 127 29 | assert curve.get_level(0) == 0 30 | assert curve.get_level(255) == 127 31 | 32 | 33 | def test_get_level_segments(): 34 | curve = SegmentsCurve() 35 | curve.add_point(2, 0) 36 | curve.add_point(3, 255) 37 | assert curve.name == "Segment" 38 | assert curve.get_level(0) == 0 39 | assert curve.get_level(3) == 255 40 | assert curve.get_level(255) == 255 41 | 42 | 43 | def test_get_level_interpolate(): 44 | curve = InterpolateCurve() 45 | curve.add_point(70, 40) 46 | assert curve.name == "Interpolate" 47 | assert curve.get_level(0) == 0 48 | assert curve.get_level(40) == 20 49 | assert curve.get_level(70) == 40 50 | assert curve.get_level(100) == 64 51 | assert curve.get_level(255) == 255 52 | 53 | 54 | def test_set_point_segments(): 55 | curve = SegmentsCurve() 56 | curve.set_point(0, 0, 255) 57 | assert curve.get_level(0) == 255 58 | -------------------------------------------------------------------------------- /test/test_import_ascii_file.py: -------------------------------------------------------------------------------- 1 | import gi 2 | 3 | gi.require_version('Gtk', '3.0') 4 | from gi.repository import Gio # noqa: E402 5 | 6 | from files.import_file import ImportFile # noqa: E402 7 | 8 | FILE_PATH = "test/sample.asc" 9 | gfile = Gio.File.new_for_path(FILE_PATH) 10 | 11 | 12 | def test_import(): 13 | imported = ImportFile(gfile, "ascii") 14 | imported.parse() 15 | assert imported.data.console["console"] == "olc" 16 | assert imported.data.console["manufacturer"] == "mika" 17 | assert imported.data.patch[7] == [(47, 1, 255)] 18 | assert imported.data.sequences[1]["steps"][0] == 1.0 19 | assert imported.data.sequences[1]["steps"][1] == 2.0 20 | assert imported.data.sequences[1]["steps"][2] == 1.0 21 | assert imported.data.sequences[1]["steps"][3] == 4.0 22 | assert imported.data.sequences[1]["cues"][2.0]["text"] == "Entrée Public" 23 | assert imported.data.sequences[1]["cues"][1.0]["channels"] == { 24 | 7: 255, 25 | 20: 255, 26 | 31: 255 27 | } 28 | assert imported.data.sequences[2]["text"] == "Chaser 1" 29 | --------------------------------------------------------------------------------