Minor fix for flake8 complaint about comment whitespace
[jack_mixer.git] / jack_mixer.py
blob0ee2cc4b171f1a1687a961ac7affb8fe7c486086
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
4 # This file is part of jack_mixer
6 # Copyright (C) 2006-2009 Nedko Arnaudov <nedko@arnaudov.name>
7 # Copyright (C) 2009-2021 Frederic Peters <fpeters@0d.be> et al.
9 # This program is free software; you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation; version 2 of the License
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with this program; if not, write to the Free Software
20 # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
22 import getpass
23 import logging
24 import datetime
25 import os
26 import re
27 import signal
28 import sys
29 from argparse import ArgumentParser
30 from urllib.parse import urlparse
32 import gi
34 gi.require_version("Gtk", "3.0")
35 from gi.repository import Gtk
36 from gi.repository import GLib
38 # temporary change Python modules lookup path to look into installation
39 # directory ($prefix/share/jack_mixer/)
40 old_path = sys.path
41 sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), "..", "share", "jack_mixer"))
43 import jack_mixer_c
45 import gui
46 import scale
47 from channel import InputChannel, NewInputChannelDialog, NewOutputChannelDialog, OutputChannel
48 from nsmclient import NSMClient
49 from serialization_xml import XmlSerialization
50 from serialization import SerializedObject, Serializator
51 from styling import load_css_styles
52 from preferences import PreferencesDialog
53 from version import __version__
55 # restore Python modules lookup path
56 sys.path = old_path
57 log = logging.getLogger("jack_mixer")
60 def add_number_suffix(s):
61 def inc(match):
62 return str(int(match.group(0)) + 1)
64 new_s = re.sub(r"(\d+)\s*$", inc, s)
65 if new_s == s:
66 new_s = s + " 1"
68 return new_s
71 class JackMixer(SerializedObject):
73 # scales suitable as meter scales
74 meter_scales = [
75 scale.K20(),
76 scale.K14(),
77 scale.IEC268(),
78 scale.Linear70dB(),
79 scale.IEC268Minimalistic(),
82 # scales suitable as volume slider scales
83 slider_scales = [scale.Linear30dB(), scale.Linear70dB()]
85 def __init__(self, client_name="jack_mixer"):
86 self.visible = False
87 self.nsm_client = None
88 # name of project file that is currently open
89 self.current_filename = None
90 self.last_project_path = None
91 self._monitored_channel = None
92 self._init_solo_channels = None
94 if os.environ.get("NSM_URL"):
95 self.nsm_client = NSMClient(
96 prettyName="jack_mixer",
97 saveCallback=self.nsm_save_cb,
98 openOrNewCallback=self.nsm_open_cb,
99 supportsSaveStatus=False,
100 hideGUICallback=self.nsm_hide_cb,
101 showGUICallback=self.nsm_show_cb,
102 exitProgramCallback=self.nsm_exit_cb,
103 loggingLevel="error",
105 self.nsm_client.announceGuiVisibility(self.visible)
106 else:
107 self.visible = True
108 self.create_mixer(client_name, with_nsm=False)
110 def create_mixer(self, client_name, with_nsm=True):
111 self.mixer = jack_mixer_c.Mixer(client_name)
112 if not self.mixer:
113 raise RuntimeError("Failed to create Mixer instance.")
115 self.create_ui(with_nsm)
116 self.window.set_title(client_name)
118 self.monitor_channel = self.mixer.add_output_channel("Monitor", True, True)
120 GLib.timeout_add(33, self.read_meters)
121 GLib.timeout_add(50, self.midi_events_check)
123 if with_nsm:
124 GLib.timeout_add(200, self.nsm_react)
126 def cleanup(self):
127 log.debug("Cleaning jack_mixer.")
128 if not self.mixer:
129 return
131 for channel in self.channels:
132 channel.unrealize()
134 self.mixer.destroy()
136 # ---------------------------------------------------------------------------------------------
137 # UI creation and (de-)initialization
139 def new_menu_item(self, title, callback=None, accel=None, enabled=True):
140 menuitem = Gtk.MenuItem.new_with_mnemonic(title)
141 menuitem.set_sensitive(enabled)
142 if callback:
143 menuitem.connect("activate", callback)
144 if accel:
145 key, mod = Gtk.accelerator_parse(accel)
146 menuitem.add_accelerator(
147 "activate", self.menu_accelgroup, key, mod, Gtk.AccelFlags.VISIBLE
149 return menuitem
151 def create_recent_file_menu(self):
152 recentmenu = Gtk.MenuItem.new_with_mnemonic('_Recent Projects')
154 self.recentmanager = Gtk.RecentManager.get_default()
155 recentchooser = Gtk.RecentChooserMenu.new_for_manager(self.recentmanager)
156 recentchooser.set_sort_type(Gtk.RecentSortType.MRU)
157 recentchooser.set_local_only(True)
158 recentchooser.set_limit(10)
159 recentchooser.set_show_icons(True)
160 recentchooser.set_show_numbers(True)
161 recentchooser.set_show_tips(True)
162 recentfilter = Gtk.RecentFilter()
163 recentfilter.add_application("jack_mixer.py")
164 recentfilter.add_application("jack_mixer")
165 recentchooser.add_filter(recentfilter)
166 recentchooser.connect('item-activated', self.on_recent_file_chosen)
167 recentmenu.set_submenu(recentchooser)
168 return recentmenu
170 def create_ui(self, with_nsm):
171 self.channels = []
172 self.output_channels = []
173 load_css_styles()
174 self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
175 self.window.set_icon_name("jack_mixer")
176 self.gui_factory = gui.Factory(self.window, self.meter_scales, self.slider_scales)
177 self.gui_factory.connect("midi-behavior-mode-changed", self.on_midi_behavior_mode_changed)
178 self.gui_factory.emit_midi_behavior_mode()
180 self.vbox_top = Gtk.VBox()
181 self.window.add(self.vbox_top)
183 self.menu_accelgroup = Gtk.AccelGroup()
184 self.window.add_accel_group(self.menu_accelgroup)
186 self.menubar = Gtk.MenuBar()
187 self.vbox_top.pack_start(self.menubar, False, True, 0)
189 mixer_menu_item = Gtk.MenuItem.new_with_mnemonic("_Mixer")
190 self.menubar.append(mixer_menu_item)
191 edit_menu_item = Gtk.MenuItem.new_with_mnemonic("_Edit")
192 self.menubar.append(edit_menu_item)
193 help_menu_item = Gtk.MenuItem.new_with_mnemonic("_Help")
194 self.menubar.append(help_menu_item)
196 self.width = 420
197 self.height = 420
198 self.paned_position = 210
199 self.window.set_default_size(self.width, self.height)
201 # Mixer (and File) menu
202 self.mixer_menu = Gtk.Menu()
203 mixer_menu_item.set_submenu(self.mixer_menu)
205 self.mixer_menu.append(
206 self.new_menu_item("New _Input Channel", self.on_add_input_channel, "<Control>N")
208 self.mixer_menu.append(
209 self.new_menu_item(
210 "New Output _Channel", self.on_add_output_channel, "<Shift><Control>N"
214 self.mixer_menu.append(Gtk.SeparatorMenuItem())
215 if not with_nsm:
216 self.mixer_menu.append(self.new_menu_item("_Open...", self.on_open_cb, "<Control>O"))
218 # Recent files sub-menu
219 self.mixer_menu.append(self.create_recent_file_menu())
221 self.mixer_menu.append(self.new_menu_item("_Save", self.on_save_cb, "<Control>S"))
223 if not with_nsm:
224 self.mixer_menu.append(
225 self.new_menu_item("Save _As...", self.on_save_as_cb, "<Shift><Control>S")
228 self.mixer_menu.append(Gtk.SeparatorMenuItem())
229 if with_nsm:
230 self.mixer_menu.append(self.new_menu_item("_Hide", self.nsm_hide_cb, "<Control>W"))
231 else:
232 self.mixer_menu.append(self.new_menu_item("_Quit", self.on_quit_cb, "<Control>Q"))
234 # Edit menu
235 edit_menu = Gtk.Menu()
236 edit_menu_item.set_submenu(edit_menu)
238 self.channel_edit_input_menu_item = self.new_menu_item(
239 "_Edit Input Channel", enabled=False
241 edit_menu.append(self.channel_edit_input_menu_item)
242 self.channel_edit_input_menu = Gtk.Menu()
243 self.channel_edit_input_menu_item.set_submenu(self.channel_edit_input_menu)
245 self.channel_edit_output_menu_item = self.new_menu_item(
246 "E_dit Output Channel", enabled=False
248 edit_menu.append(self.channel_edit_output_menu_item)
249 self.channel_edit_output_menu = Gtk.Menu()
250 self.channel_edit_output_menu_item.set_submenu(self.channel_edit_output_menu)
252 self.channel_remove_input_menu_item = self.new_menu_item(
253 "_Remove Input Channel", enabled=False
255 edit_menu.append(self.channel_remove_input_menu_item)
256 self.channel_remove_input_menu = Gtk.Menu()
257 self.channel_remove_input_menu_item.set_submenu(self.channel_remove_input_menu)
259 self.channel_remove_output_menu_item = self.new_menu_item(
260 "Re_move Output Channel", enabled=False
262 edit_menu.append(self.channel_remove_output_menu_item)
263 self.channel_remove_output_menu = Gtk.Menu()
264 self.channel_remove_output_menu_item.set_submenu(self.channel_remove_output_menu)
266 edit_menu.append(Gtk.SeparatorMenuItem())
267 edit_menu.append(
268 self.new_menu_item("Shrink Channels", self.on_shrink_channels_cb, "<Control>minus")
270 edit_menu.append(
271 self.new_menu_item("Expand Channels", self.on_expand_channels_cb, "<Control>plus")
273 edit_menu.append(Gtk.SeparatorMenuItem())
275 edit_menu.append(self.new_menu_item("_Clear", self.on_channels_clear, "<Control>X"))
276 edit_menu.append(Gtk.SeparatorMenuItem())
278 self.preferences_dialog = None
279 edit_menu.append(self.new_menu_item("_Preferences", self.on_preferences_cb, "<Control>P"))
281 # Help menu
282 help_menu = Gtk.Menu()
283 help_menu_item.set_submenu(help_menu)
285 help_menu.append(self.new_menu_item("_About", self.on_about, "F1"))
287 # Main panel
288 self.hbox_top = Gtk.HBox()
289 self.vbox_top.pack_start(self.hbox_top, True, True, 0)
291 self.scrolled_window = Gtk.ScrolledWindow()
292 self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
294 self.hbox_inputs = Gtk.Box()
295 self.hbox_inputs.set_spacing(0)
296 self.hbox_inputs.set_border_width(0)
297 self.hbox_top.set_spacing(0)
298 self.hbox_top.set_border_width(0)
299 self.scrolled_window.add(self.hbox_inputs)
300 self.hbox_outputs = Gtk.Box()
301 self.hbox_outputs.set_spacing(0)
302 self.hbox_outputs.set_border_width(0)
303 self.scrolled_output = Gtk.ScrolledWindow()
304 self.scrolled_output.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
305 self.scrolled_output.add(self.hbox_outputs)
306 self.paned = Gtk.HPaned()
307 self.paned.set_wide_handle(True)
308 self.hbox_top.pack_start(self.paned, True, True, 0)
309 self.paned.pack1(self.scrolled_window, True, False)
310 self.paned.pack2(self.scrolled_output, True, False)
312 self.window.connect("destroy", Gtk.main_quit)
313 self.window.connect("delete-event", self.on_delete_event)
315 # ---------------------------------------------------------------------------------------------
316 # Channel creation
318 def add_channel(self, name, stereo, volume_cc, balance_cc, mute_cc, solo_cc, value):
319 try:
320 channel = InputChannel(self, name, stereo, value)
321 self.add_channel_precreated(channel)
322 except Exception:
323 error_dialog(self.window, "Input channel creation failed.")
324 return
326 channel.assign_midi_ccs(volume_cc, balance_cc, mute_cc, solo_cc)
327 return channel
329 def add_channel_precreated(self, channel):
330 frame = Gtk.Frame()
331 frame.add(channel)
332 self.hbox_inputs.pack_start(frame, False, True, 0)
333 channel.realize()
335 channel_edit_menu_item = Gtk.MenuItem(label=channel.channel_name)
336 self.channel_edit_input_menu.append(channel_edit_menu_item)
337 channel_edit_menu_item.connect("activate", self.on_edit_input_channel, channel)
338 self.channel_edit_input_menu_item.set_sensitive(True)
340 channel_remove_menu_item = Gtk.MenuItem(label=channel.channel_name)
341 self.channel_remove_input_menu.append(channel_remove_menu_item)
342 channel_remove_menu_item.connect("activate", self.on_remove_input_channel, channel)
343 self.channel_remove_input_menu_item.set_sensitive(True)
345 self.channels.append(channel)
347 for outputchannel in self.output_channels:
348 channel.add_control_group(outputchannel)
350 # create post fader output channel matching the input channel
351 channel.post_fader_output_channel = self.mixer.add_output_channel(
352 channel.channel.name + " Out", channel.channel.is_stereo, True
354 channel.post_fader_output_channel.volume = 0
355 channel.post_fader_output_channel.set_solo(channel.channel, True)
357 channel.connect("input-channel-order-changed", self.on_input_channel_order_changed)
359 def add_output_channel(
360 self, name, stereo, volume_cc, balance_cc, mute_cc, display_solo_buttons, color, value
362 try:
363 channel = OutputChannel(self, name, stereo, value)
364 channel.display_solo_buttons = display_solo_buttons
365 channel.color = color
366 self.add_output_channel_precreated(channel)
367 except Exception:
368 error_dialog(self.window, "Output channel creation failed")
369 return
371 channel.assign_midi_ccs(volume_cc, balance_cc, mute_cc)
372 return channel
374 def add_output_channel_precreated(self, channel):
375 frame = Gtk.Frame()
376 frame.add(channel)
377 self.hbox_outputs.pack_end(frame, False, True, 0)
378 self.hbox_outputs.reorder_child(frame, 0)
379 channel.realize()
381 channel_edit_menu_item = Gtk.MenuItem(label=channel.channel_name)
382 self.channel_edit_output_menu.append(channel_edit_menu_item)
383 channel_edit_menu_item.connect("activate", self.on_edit_output_channel, channel)
384 self.channel_edit_output_menu_item.set_sensitive(True)
386 channel_remove_menu_item = Gtk.MenuItem(label=channel.channel_name)
387 self.channel_remove_output_menu.append(channel_remove_menu_item)
388 channel_remove_menu_item.connect("activate", self.on_remove_output_channel, channel)
389 self.channel_remove_output_menu_item.set_sensitive(True)
391 self.output_channels.append(channel)
392 channel.connect("output-channel-order-changed", self.on_output_channel_order_changed)
394 # ---------------------------------------------------------------------------------------------
395 # Signal/event handlers
397 # ---------------------------------------------------------------------------------------------
398 # NSM
400 def nsm_react(self):
401 self.nsm_client.reactToMessage()
402 return True
404 def nsm_hide_cb(self, *args):
405 self.window.hide()
406 self.visible = False
407 self.nsm_client.announceGuiVisibility(False)
409 def nsm_show_cb(self):
410 width, height = self.window.get_size()
411 self.window.show_all()
412 self.paned.set_position(self.paned_position / self.width * width)
414 self.visible = True
415 self.nsm_client.announceGuiVisibility(True)
417 def nsm_open_cb(self, path, session_name, client_name):
418 self.create_mixer(client_name, with_nsm=True)
419 self.current_filename = path + ".xml"
420 if os.path.isfile(self.current_filename):
421 try:
422 with open(self.current_filename, "r") as fp:
423 self.load_from_xml(fp, from_nsm=True)
424 except Exception as exc:
425 # Re-raise with more meaningful error message
426 raise IOError(
427 "Error loading project file '{}': {}".format(self.current_filename, exc)
430 def nsm_save_cb(self, path, session_name, client_name):
431 self.current_filename = path + ".xml"
432 f = open(self.current_filename, "w")
433 self.save_to_xml(f)
434 f.close()
436 def nsm_exit_cb(self, path, session_name, client_name):
437 Gtk.main_quit()
439 # ---------------------------------------------------------------------------------------------
440 # POSIX signals
442 def sighandler(self, signum, frame):
443 log.debug("Signal %d received.", signum)
444 if signum == signal.SIGUSR1:
445 GLib.timeout_add(0, self.on_save_cb)
446 elif signum == signal.SIGINT or signal == signal.SIGTERM:
447 GLib.timeout_add(0, self.on_quit_cb)
448 else:
449 log.warning("Unknown signal %d received.", signum)
451 # ---------------------------------------------------------------------------------------------
452 # GTK signals
454 def on_about(self, *args):
455 about = Gtk.AboutDialog()
456 about.set_name("jack_mixer")
457 about.set_program_name("jack_mixer")
458 about.set_copyright(
459 "Copyright © 2006-2021\n"
460 "Nedko Arnaudov,\n"
461 "Frédéric Péters, Arnout Engelen,\n"
462 "Daniel Sheeler, Christopher Arndt"
464 about.set_license(
465 """\
466 jack_mixer is free software; you can redistribute it and/or modify it
467 under the terms of the GNU General Public License as published by the
468 Free Software Foundation; either version 2 of the License, or (at your
469 option) any later version.
471 jack_mixer is distributed in the hope that it will be useful, but
472 WITHOUT ANY WARRANTY; without even the implied warranty of
473 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
474 General Public License for more details.
476 You should have received a copy of the GNU General Public License along
477 with jack_mixer; if not, write to the Free Software Foundation, Inc., 51
478 Franklin Street, Fifth Floor, Boston, MA 02110-130159 USA"""
480 about.set_authors(
482 "Nedko Arnaudov <nedko@arnaudov.name>",
483 "Christopher Arndt <chris@chrisarndt.de>",
484 "Arnout Engelen <arnouten@bzzt.net>",
485 "John Hedges <john@drystone.co.uk>",
486 "Olivier Humbert <trebmuh@tuxfamily.org>",
487 "Sarah Mischke <sarah@spooky-online.de>",
488 "Frédéric Péters <fpeters@0d.be>",
489 "Daniel Sheeler <dsheeler@pobox.com>",
490 "Athanasios Silis <athanasios.silis@gmail.com>",
493 about.set_logo_icon_name("jack_mixer")
494 about.set_version(__version__)
495 about.set_website("https://rdio.space/jackmixer/")
496 about.run()
497 about.destroy()
499 def on_delete_event(self, widget, event):
500 if self.nsm_client:
501 self.nsm_hide_cb()
502 return True
504 return self.on_quit_cb(on_delete=True)
506 def add_file_filters(self, dialog):
507 filter_xml = Gtk.FileFilter()
508 filter_xml.set_name("XML files")
509 filter_xml.add_mime_type("text/xml")
510 dialog.add_filter(filter_xml)
511 filter_all = Gtk.FileFilter()
512 filter_all.set_name("All files")
513 filter_all.add_pattern("*")
514 dialog.add_filter(filter_all)
516 def _open_project(self, filename):
517 try:
518 with open(filename, "r") as fp:
519 self.load_from_xml(fp)
520 except Exception as exc:
521 error_dialog(self.window, "Error loading project file '%s': %s", filename, exc)
522 else:
523 self.current_filename = filename
524 return True
526 def on_open_cb(self, *args):
527 dlg = Gtk.FileChooserDialog(
528 title="Open project", parent=self.window, action=Gtk.FileChooserAction.OPEN
530 dlg.add_buttons(
531 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK
533 dlg.set_default_response(Gtk.ResponseType.OK)
535 default_project_path = self.gui_factory.get_default_project_path()
537 if self.current_filename:
538 dlg.set_current_folder(os.path.dirname(self.current_filename))
539 else:
540 dlg.set_current_folder(self.last_project_path or default_project_path or os.getcwd())
542 if default_project_path:
543 dlg.add_shortcut_folder(default_project_path)
545 self.add_file_filters(dlg)
547 if dlg.run() == Gtk.ResponseType.OK:
548 filename = dlg.get_filename()
549 if self._open_project(filename):
550 self.recentmanager.add_item("file://" + os.path.abspath(filename))
552 dlg.destroy()
554 def on_recent_file_chosen(self, recentchooser):
555 item = recentchooser.get_current_item()
557 if item and item.exists():
558 log.debug("Recent file menu entry selected: %s", item.get_display_name())
559 uri = item.get_uri()
560 if not self._open_project(urlparse(uri).path):
561 self.recentmanager.remove_item(uri)
563 def _save_project(self, filename):
564 with open(filename, "w") as fp:
565 self.save_to_xml(fp)
567 def on_save_cb(self, *args):
568 if not self.current_filename:
569 return self.on_save_as_cb()
571 try:
572 self._save_project(self.current_filename)
573 except Exception as exc:
574 error_dialog(self.window, "Error saving project file '%s': %s",
575 self.current_filename, exc)
577 def on_save_as_cb(self, *args):
578 dlg = Gtk.FileChooserDialog(
579 title="Save project", parent=self.window, action=Gtk.FileChooserAction.SAVE
581 dlg.add_buttons(
582 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK
584 dlg.set_default_response(Gtk.ResponseType.OK)
585 dlg.set_do_overwrite_confirmation(True)
587 default_project_path = self.gui_factory.get_default_project_path()
589 if self.current_filename:
590 dlg.set_filename(self.current_filename)
591 else:
592 dlg.set_current_folder(self.last_project_path or default_project_path or os.getcwd())
593 filename = "{}-{}.xml".format(
594 getpass.getuser(), datetime.datetime.now().strftime('%Y%m%d-%H%M'))
595 dlg.set_current_name(filename)
597 if default_project_path:
598 dlg.add_shortcut_folder(default_project_path)
600 self.add_file_filters(dlg)
602 if dlg.run() == Gtk.ResponseType.OK:
603 save_path = dlg.get_filename()
604 save_dir = os.path.dirname(save_path)
605 if os.path.isdir(save_dir):
606 self.last_project_path = save_dir
608 filename = dlg.get_filename()
609 try:
610 self._save_project(filename)
611 except Exception as exc:
612 error_dialog(self.window, "Error saving project file '%s': %s", filename, exc)
613 else:
614 self.current_filename = filename
615 self.recentmanager.add_item("file://" + os.path.abspath(filename))
617 dlg.destroy()
619 def on_quit_cb(self, *args, on_delete=False):
620 if not self.nsm_client and self.gui_factory.get_confirm_quit():
621 dlg = Gtk.MessageDialog(
622 parent=self.window,
623 message_type=Gtk.MessageType.QUESTION,
624 buttons=Gtk.ButtonsType.NONE,
626 dlg.set_markup("<b>Quit application?</b>")
627 dlg.format_secondary_markup(
628 "All jack_mixer ports will be closed and connections lost,"
629 "\nstopping all sound going through jack_mixer.\n\n"
630 "Are you sure?"
632 dlg.add_buttons(
633 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_QUIT, Gtk.ResponseType.OK
635 response = dlg.run()
636 dlg.destroy()
637 if response != Gtk.ResponseType.OK:
638 return on_delete
640 Gtk.main_quit()
642 def on_shrink_channels_cb(self, widget):
643 for channel in self.channels + self.output_channels:
644 channel.narrow()
646 def on_expand_channels_cb(self, widget):
647 for channel in self.channels + self.output_channels:
648 channel.widen()
650 def on_midi_behavior_mode_changed(self, gui_factory, value):
651 self.mixer.midi_behavior_mode = value
653 def on_preferences_cb(self, widget):
654 if not self.preferences_dialog:
655 self.preferences_dialog = PreferencesDialog(self)
656 self.preferences_dialog.show()
657 self.preferences_dialog.present()
659 def on_add_channel(self, inout="input", default_name="Input"):
660 dialog = getattr(self, "_add_{}_dialog".format(inout), None)
661 values = getattr(self, "_add_{}_values".format(inout), {})
663 if dialog is None:
664 cls = NewInputChannelDialog if inout == "input" else NewOutputChannelDialog
665 dialog = cls(app=self)
666 setattr(self, "_add_{}_dialog".format(inout), dialog)
668 names = {
669 ch.channel_name for ch in (self.channels if inout == "input" else self.output_channels)
671 values.setdefault("name", default_name)
672 while True:
673 if values["name"] in names:
674 values["name"] = add_number_suffix(values["name"])
675 else:
676 break
678 dialog.fill_ui(**values)
679 dialog.set_transient_for(self.window)
680 dialog.show()
681 ret = dialog.run()
682 dialog.hide()
684 if ret == Gtk.ResponseType.OK:
685 result = dialog.get_result()
686 setattr(self, "_add_{}_values".format(inout), result)
687 (self.add_channel if inout == "input" else self.add_output_channel)(**result)
688 if self.visible or self.nsm_client is None:
689 self.window.show_all()
691 def on_add_input_channel(self, widget):
692 return self.on_add_channel("input", "Input")
694 def on_add_output_channel(self, widget):
695 return self.on_add_channel("output", "Output")
697 def on_edit_input_channel(self, widget, channel):
698 log.debug('Editing input channel "%s".', channel.channel_name)
699 channel.on_channel_properties()
701 def on_remove_input_channel(self, widget, channel):
702 log.debug('Removing input channel "%s".', channel.channel_name)
704 def remove_channel_edit_input_menuitem_by_label(widget, label):
705 if widget.get_label() == label:
706 self.channel_edit_input_menu.remove(widget)
708 self.channel_remove_input_menu.remove(widget)
709 self.channel_edit_input_menu.foreach(
710 remove_channel_edit_input_menuitem_by_label, channel.channel_name
713 if self.monitored_channel is channel:
714 channel.monitor_button.set_active(False)
716 for i in range(len(self.channels)):
717 if self.channels[i] is channel:
718 channel.unrealize()
719 del self.channels[i]
720 self.hbox_inputs.remove(channel.get_parent())
721 break
723 if not self.channels:
724 self.channel_edit_input_menu_item.set_sensitive(False)
725 self.channel_remove_input_menu_item.set_sensitive(False)
727 def on_edit_output_channel(self, widget, channel):
728 log.debug('Editing output channel "%s".', channel.channel_name)
729 channel.on_channel_properties()
731 def on_remove_output_channel(self, widget, channel):
732 log.debug('Removing output channel "%s".', channel.channel_name)
734 def remove_channel_edit_output_menuitem_by_label(widget, label):
735 if widget.get_label() == label:
736 self.channel_edit_output_menu.remove(widget)
738 self.channel_remove_output_menu.remove(widget)
739 self.channel_edit_output_menu.foreach(
740 remove_channel_edit_output_menuitem_by_label, channel.channel_name
743 if self.monitored_channel is channel:
744 channel.monitor_button.set_active(False)
746 for i in range(len(self.channels)):
747 if self.output_channels[i] is channel:
748 channel.unrealize()
749 del self.output_channels[i]
750 self.hbox_outputs.remove(channel.get_parent())
751 break
753 if not self.output_channels:
754 self.channel_edit_output_menu_item.set_sensitive(False)
755 self.channel_remove_output_menu_item.set_sensitive(False)
757 def on_channel_rename(self, oldname, newname):
758 def rename_channels(container, parameters):
759 if container.get_label() == parameters["oldname"]:
760 container.set_label(parameters["newname"])
762 rename_parameters = {"oldname": oldname, "newname": newname}
763 self.channel_edit_input_menu.foreach(rename_channels, rename_parameters)
764 self.channel_edit_output_menu.foreach(rename_channels, rename_parameters)
765 self.channel_remove_input_menu.foreach(rename_channels, rename_parameters)
766 self.channel_remove_output_menu.foreach(rename_channels, rename_parameters)
767 log.debug('Renaming channel from "%s" to "%s".', oldname, newname)
769 def on_input_channel_order_changed(self, widget, source_name, dest_name):
770 self.channels.clear()
772 channel_box = self.hbox_inputs
773 frames = channel_box.get_children()
775 for f in frames:
776 c = f.get_child()
777 if source_name == c._channel_name:
778 source_frame = f
779 break
781 for f in frames:
782 c = f.get_child()
783 if dest_name == c._channel_name:
784 pos = frames.index(f)
785 channel_box.reorder_child(source_frame, pos)
786 break
788 for frame in self.hbox_inputs.get_children():
789 c = frame.get_child()
790 self.channels.append(c)
792 def on_output_channel_order_changed(self, widget, source_name, dest_name):
793 self.output_channels.clear()
794 channel_box = self.hbox_outputs
796 frames = channel_box.get_children()
798 for f in frames:
799 c = f.get_child()
800 if source_name == c._channel_name:
801 source_frame = f
802 break
804 for f in frames:
805 c = f.get_child()
806 if dest_name == c._channel_name:
807 pos = len(frames) - 1 - frames.index(f)
808 channel_box.reorder_child(source_frame, pos)
809 break
811 for frame in self.hbox_outputs.get_children():
812 c = frame.get_child()
813 self.output_channels.append(c)
815 def on_channels_clear(self, widget):
816 dlg = Gtk.MessageDialog(
817 parent=self.window,
818 modal=True,
819 message_type=Gtk.MessageType.WARNING,
820 text="Are you sure you want to clear all channels?",
821 buttons=Gtk.ButtonsType.OK_CANCEL,
824 if not widget or dlg.run() == Gtk.ResponseType.OK:
825 for channel in self.output_channels:
826 channel.unrealize()
827 self.hbox_outputs.remove(channel.get_parent())
828 for channel in self.channels:
829 channel.unrealize()
830 self.hbox_inputs.remove(channel.get_parent())
831 self.channels = []
832 self.output_channels = []
833 self.channel_edit_input_menu = Gtk.Menu()
834 self.channel_edit_input_menu_item.set_submenu(self.channel_edit_input_menu)
835 self.channel_edit_input_menu_item.set_sensitive(False)
836 self.channel_remove_input_menu = Gtk.Menu()
837 self.channel_remove_input_menu_item.set_submenu(self.channel_remove_input_menu)
838 self.channel_remove_input_menu_item.set_sensitive(False)
839 self.channel_edit_output_menu = Gtk.Menu()
840 self.channel_edit_output_menu_item.set_submenu(self.channel_edit_output_menu)
841 self.channel_edit_output_menu_item.set_sensitive(False)
842 self.channel_remove_output_menu = Gtk.Menu()
843 self.channel_remove_output_menu_item.set_submenu(self.channel_remove_output_menu)
844 self.channel_remove_output_menu_item.set_sensitive(False)
846 # Force save-as dialog on next save
847 self.current_filename = None
849 dlg.destroy()
851 def read_meters(self):
852 for channel in self.channels:
853 channel.read_meter()
854 for channel in self.output_channels:
855 channel.read_meter()
856 return True
858 def midi_events_check(self):
859 for channel in self.channels + self.output_channels:
860 channel.midi_events_check()
861 return True
863 def get_monitored_channel(self):
864 return self._monitored_channel
866 def set_monitored_channel(self, channel):
867 if channel == self._monitored_channel:
868 return
869 self._monitored_channel = channel
870 if channel is None:
871 self.monitor_channel.out_mute = True
872 elif isinstance(channel, InputChannel):
873 # reset all solo/mute settings
874 for in_channel in self.channels:
875 self.monitor_channel.set_solo(in_channel.channel, False)
876 self.monitor_channel.set_muted(in_channel.channel, False)
877 self.monitor_channel.set_solo(channel.channel, True)
878 self.monitor_channel.prefader = True
879 self.monitor_channel.out_mute = False
880 else:
881 self.monitor_channel.prefader = False
882 self.monitor_channel.out_mute = False
884 if channel:
885 self.update_monitor(channel)
887 monitored_channel = property(get_monitored_channel, set_monitored_channel)
889 def update_monitor(self, channel):
890 if self._monitored_channel is not channel:
891 return
892 self.monitor_channel.volume = channel.channel.volume
893 self.monitor_channel.balance = channel.channel.balance
894 if isinstance(self.monitored_channel, OutputChannel):
895 # sync solo/muted channels
896 for input_channel in self.channels:
897 self.monitor_channel.set_solo(
898 input_channel.channel, channel.channel.is_solo(input_channel.channel)
900 self.monitor_channel.set_muted(
901 input_channel.channel, channel.channel.is_muted(input_channel.channel)
904 def get_input_channel_by_name(self, name):
905 for input_channel in self.channels:
906 if input_channel.channel.name == name:
907 return input_channel
908 return None
910 # ---------------------------------------------------------------------------------------------
911 # Mixer project (de-)serialization and file handling
913 def save_to_xml(self, file):
914 log.debug("Saving to XML...")
915 b = XmlSerialization()
916 s = Serializator()
917 s.serialize(self, b)
918 b.save(file)
920 def load_from_xml(self, file, silence_errors=False, from_nsm=False):
921 log.debug("Loading from XML...")
922 self.unserialized_channels = []
923 b = XmlSerialization()
924 try:
925 b.load(file, self.serialization_name())
926 except: # noqa: E722
927 if silence_errors:
928 return
929 raise
931 self.on_channels_clear(None)
932 s = Serializator()
933 s.unserialize(self, b)
934 for channel in self.unserialized_channels:
935 if isinstance(channel, InputChannel):
936 if self._init_solo_channels and channel.channel_name in self._init_solo_channels:
937 channel.solo = True
938 self.add_channel_precreated(channel)
939 self._init_solo_channels = None
940 for channel in self.unserialized_channels:
941 if isinstance(channel, OutputChannel):
942 self.add_output_channel_precreated(channel)
943 del self.unserialized_channels
944 width, height = self.window.get_size()
945 if self.visible or not from_nsm:
946 self.window.show_all()
948 if self.output_channels:
949 self.output_channels[-1].volume_digits.select_region(0, 0)
950 self.output_channels[-1].slider.grab_focus()
951 elif self.channels:
952 self.channels[-1].volume_digits.select_region(0, 0)
953 self.channels[-1].volume_digits.grab_focus()
955 self.paned.set_position(self.paned_position / self.width * width)
956 self.window.resize(self.width, self.height)
958 def serialize(self, object_backend):
959 width, height = self.window.get_size()
960 object_backend.add_property("geometry", "%sx%s" % (width, height))
961 pos = self.paned.get_position()
962 object_backend.add_property("paned_position", "%s" % pos)
963 solo_channels = []
964 for input_channel in self.channels:
965 if input_channel.channel.solo:
966 solo_channels.append(input_channel)
967 if solo_channels:
968 object_backend.add_property(
969 "solo_channels", "|".join([x.channel.name for x in solo_channels])
971 object_backend.add_property("visible", "%s" % str(self.visible))
973 def unserialize_property(self, name, value):
974 if name == "geometry":
975 width, height = value.split("x")
976 self.width = int(width)
977 self.height = int(height)
978 return True
979 if name == "solo_channels":
980 self._init_solo_channels = value.split("|")
981 return True
982 if name == "visible":
983 self.visible = value == "True"
984 return True
985 if name == "paned_position":
986 self.paned_position = int(value)
987 return True
988 return False
990 def unserialize_child(self, name):
991 if name == InputChannel.serialization_name():
992 channel = InputChannel(self, "", True)
993 self.unserialized_channels.append(channel)
994 return channel
996 if name == OutputChannel.serialization_name():
997 channel = OutputChannel(self, "", True)
998 self.unserialized_channels.append(channel)
999 return channel
1001 if name == gui.Factory.serialization_name():
1002 return self.gui_factory
1004 def serialization_get_childs(self):
1005 """Get child objects that required and support serialization"""
1006 childs = self.channels[:] + self.output_channels[:] + [self.gui_factory]
1007 return childs
1009 def serialization_name(self):
1010 return "jack_mixer"
1012 # ---------------------------------------------------------------------------------------------
1013 # Main program loop
1015 def main(self):
1016 if not self.mixer:
1017 return
1019 if self.visible or self.nsm_client is None:
1020 width, height = self.window.get_size()
1021 self.window.show_all()
1022 if hasattr(self, "paned_position"):
1023 self.paned.set_position(self.paned_position / self.width * width)
1025 signal.signal(signal.SIGUSR1, self.sighandler)
1026 signal.signal(signal.SIGTERM, self.sighandler)
1027 signal.signal(signal.SIGINT, self.sighandler)
1028 signal.signal(signal.SIGHUP, signal.SIG_IGN)
1030 Gtk.main()
1033 def error_dialog(parent, msg, *args):
1034 log.exception(msg, *args)
1035 err = Gtk.MessageDialog(
1036 parent=parent,
1037 modal=True,
1038 destroy_with_parent=True,
1039 message_type=Gtk.MessageType.ERROR,
1040 buttons=Gtk.ButtonsType.OK,
1041 text=msg % args,
1043 err.run()
1044 err.destroy()
1047 def main():
1048 parser = ArgumentParser()
1049 parser.add_argument(
1050 "-c", "--config", metavar="FILE", help="load mixer project configuration from FILE"
1052 parser.add_argument("-d", "--debug", action="store_true", help="enable debug logging messages")
1053 parser.add_argument(
1054 "client_name", metavar="NAME", nargs="?", default="jack_mixer", help="set JACK client name"
1056 args = parser.parse_args()
1058 logging.basicConfig(
1059 level=logging.DEBUG if args.debug else logging.INFO, format="%(levelname)s: %(message)s"
1062 try:
1063 mixer = JackMixer(args.client_name)
1064 except Exception as e:
1065 error_dialog(None, "Mixer creation failed:\n\n%s", e)
1066 sys.exit(1)
1068 if not mixer.nsm_client and args.config:
1069 try:
1070 with open(args.config) as fp:
1071 mixer.load_from_xml(fp)
1072 except Exception as exc:
1073 error_dialog(mixer.window, "Error loading project file '%s': %s", args.config, exc)
1074 else:
1075 mixer.current_filename = args.config
1077 mixer.window.set_default_size(
1078 60 * (1 + len(mixer.channels) + len(mixer.output_channels)), 300
1081 mixer.main()
1083 mixer.cleanup()
1086 if __name__ == "__main__":
1087 main()