fix: make pre/post fader metering switch label/tooltip translatable
[jack_mixer.git] / jack_mixer / app.py
blob15e5eea995c54812e742c2dc793fbf9fa1279fee
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.
10 import getpass
11 import gettext
12 import logging
13 import datetime
14 import os
15 import re
16 import signal
17 import sys
18 from os.path import abspath, dirname, isdir, isfile, join
19 from urllib.parse import urlparse
21 import gi
23 gi.require_version("Gtk", "3.0")
24 from gi.repository import Gtk
25 from gi.repository import GLib
27 from . import gui
28 from . import scale
29 from ._jack_mixer import Mixer
30 from .channel import InputChannel, NewInputChannelDialog, NewOutputChannelDialog, OutputChannel
31 from .nsmclient import NSMClient
32 from .preferences import PreferencesDialog
33 from .serialization_xml import XmlSerialization
34 from .serialization import SerializedObject, Serializator
35 from .styling import load_css_styles
36 from .version import __version__
39 __program__ = "jack_mixer"
40 # A "locale" directory present within the package take precedence
41 _pkglocdir = join(abspath(dirname(__file__)), "locale")
42 # unless overwritten by the "LOCALEDIR environment variable.
43 # Fall back to the system default locale directory.
44 _localedir = os.environ.get("LOCALEDIR", _pkglocdir if isdir(_pkglocdir) else None)
45 translation = gettext.translation(__program__, _localedir, fallback=True)
46 translation.install()
47 log = logging.getLogger(__program__)
48 __doc__ = _("A multi-channel audio mixer application for the JACK Audio Connection Kit.")
49 __license__ = _("""\
50 jack_mixer is free software; you can redistribute it and/or modify it
51 under the terms of the GNU General Public License as published by the
52 Free Software Foundation; either version 2 of the License, or (at your
53 option) any later version.
55 jack_mixer is distributed in the hope that it will be useful, but
56 WITHOUT ANY WARRANTY; without even the implied warranty of
57 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
58 General Public License for more details.
60 You should have received a copy of the GNU General Public License along
61 with jack_mixer; if not, write to the Free Software Foundation, Inc., 51
62 Franklin Street, Fifth Floor, Boston, MA 02110-130159 USA
63 """)
65 # Hack argparse and delay its import to get it to use our translations
66 gettext.gettext, gettext.ngettext = translation.gettext, translation.ngettext
67 import argparse
70 def add_number_suffix(s):
71 def inc(match):
72 return str(int(match.group(0)) + 1)
74 new_s = re.sub(r"(\d+)\s*$", inc, s)
75 if new_s == s:
76 new_s = s + " 1"
78 return new_s
81 class JackMixer(SerializedObject):
83 # scales suitable as meter scales
84 meter_scales = [
85 scale.K20(),
86 scale.K14(),
87 scale.IEC268(),
88 scale.Linear70dB(),
89 scale.IEC268Minimalistic(),
92 # scales suitable as volume slider scales
93 slider_scales = [scale.Linear30dB(), scale.Linear70dB()]
95 def __init__(self, client_name=__program__):
96 self.visible = False
97 self.nsm_client = None
98 # name of project file that is currently open
99 self.current_filename = None
100 self.last_project_path = None
101 self._monitored_channel = None
102 self._init_solo_channels = None
104 if os.environ.get("NSM_URL"):
105 self.nsm_client = NSMClient(
106 prettyName=__program__,
107 saveCallback=self.nsm_save_cb,
108 openOrNewCallback=self.nsm_open_cb,
109 supportsSaveStatus=False,
110 hideGUICallback=self.nsm_hide_cb,
111 showGUICallback=self.nsm_show_cb,
112 exitProgramCallback=self.nsm_exit_cb,
113 loggingLevel="error",
115 self.nsm_client.announceGuiVisibility(self.visible)
116 else:
117 self.visible = True
118 self.create_mixer(client_name, with_nsm=False)
120 def create_mixer(self, client_name, with_nsm=True):
121 self.mixer = Mixer(client_name)
122 self.create_ui(with_nsm)
123 self.window.set_title(client_name)
124 # Port names, which are not user-settable are not marked as translatable,
125 # so they are the same regardless of the language setting of the environment
126 # in which a project is loaded.
127 self.monitor_channel = self.mixer.add_output_channel("Monitor", True, True)
129 self.meter_refresh_period = \
130 self.gui_factory.get_meter_refresh_period_milliseconds()
131 self.meter_refresh_timer_id = GLib.timeout_add(self.meter_refresh_period, self.read_meters)
132 GLib.timeout_add(50, self.midi_events_check)
134 if with_nsm:
135 GLib.timeout_add(200, self.nsm_react)
137 def cleanup(self):
138 log.debug("Cleaning jack_mixer.")
139 if not self.mixer:
140 return
142 for channel in self.channels:
143 channel.unrealize()
145 self.mixer.destroy()
147 # ---------------------------------------------------------------------------------------------
148 # UI creation and (de-)initialization
150 def new_menu_item(self, title, callback=None, accel=None, enabled=True):
151 menuitem = Gtk.MenuItem.new_with_mnemonic(title)
152 menuitem.set_sensitive(enabled)
153 if callback:
154 menuitem.connect("activate", callback)
155 if accel:
156 self.menu_item_add_accelerator(menuitem, accel)
157 return menuitem
159 def menu_item_add_accelerator(self, menuitem, accel):
160 key, mod = Gtk.accelerator_parse(accel)
161 menuitem.add_accelerator(
162 "activate", self.menu_accelgroup, key, mod, Gtk.AccelFlags.VISIBLE
165 def create_recent_file_menu(self):
166 def filter_func(item):
167 return (
168 item.mime_type in ("text/xml", "application/xml")
169 and __program__ in item.applications
172 filter_flags = Gtk.RecentFilterFlags.MIME_TYPE | Gtk.RecentFilterFlags.APPLICATION
173 recentfilter = Gtk.RecentFilter()
174 recentfilter.set_name(_("jack_mixer XML files"))
175 recentfilter.add_custom(filter_flags, filter_func)
177 recentchooser = Gtk.RecentChooserMenu.new_for_manager(self.recentmanager)
178 recentchooser.set_sort_type(Gtk.RecentSortType.MRU)
179 recentchooser.set_local_only(True)
180 recentchooser.set_limit(10)
181 recentchooser.set_show_icons(True)
182 recentchooser.set_show_numbers(True)
183 recentchooser.set_show_tips(True)
184 recentchooser.add_filter(recentfilter)
185 recentchooser.connect("item-activated", self.on_recent_file_chosen)
187 recentmenu = Gtk.MenuItem.new_with_mnemonic(_("_Recent Projects"))
188 recentmenu.set_submenu(recentchooser)
189 return recentmenu
191 def create_ui(self, with_nsm):
192 self.channels = []
193 self.output_channels = []
194 load_css_styles()
196 # Main window
197 self.width = 420
198 self.height = 420
199 self.paned_position = 210
200 self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
201 self.window.set_icon_name(__program__)
202 self.window.set_default_size(self.width, self.height)
204 self.gui_factory = gui.Factory(self.window, self.meter_scales, self.slider_scales)
205 self.gui_factory.connect("language-changed", self.on_language_changed)
206 self.gui_factory.emit("language-changed", self.gui_factory.get_language())
207 self.gui_factory.connect("midi-behavior-mode-changed", self.on_midi_behavior_mode_changed)
208 self.gui_factory.connect(
209 "default-meter-scale-changed", self.on_default_meter_scale_changed
211 self.gui_factory.connect(
212 "meter-refresh-period-milliseconds-changed",
213 self.on_meter_refresh_period_milliseconds_changed
215 self.gui_factory.emit_midi_behavior_mode()
217 # Recent files manager
218 self.recentmanager = Gtk.RecentManager.get_default()
220 self.vbox_top = Gtk.VBox()
221 self.window.add(self.vbox_top)
223 self.menu_accelgroup = Gtk.AccelGroup()
224 self.window.add_accel_group(self.menu_accelgroup)
226 # Main Menu
227 self.menubar = Gtk.MenuBar()
228 self.vbox_top.pack_start(self.menubar, False, True, 0)
230 mixer_menu_item = Gtk.MenuItem.new_with_mnemonic(_("_Mixer"))
231 self.menubar.append(mixer_menu_item)
232 edit_menu_item = Gtk.MenuItem.new_with_mnemonic(_("_Edit"))
233 self.menubar.append(edit_menu_item)
234 help_menu_item = Gtk.MenuItem.new_with_mnemonic(_("_Help"))
235 self.menubar.append(help_menu_item)
237 # Mixer (and File) menu
238 self.mixer_menu = Gtk.Menu()
239 mixer_menu_item.set_submenu(self.mixer_menu)
241 self.mixer_menu.append(
242 self.new_menu_item(_("New _Input Channel"), self.on_add_input_channel, "<Control>N")
244 self.mixer_menu.append(
245 self.new_menu_item(
246 _("New Output _Channel"), self.on_add_output_channel, "<Shift><Control>N"
250 self.mixer_menu.append(Gtk.SeparatorMenuItem())
251 if not with_nsm:
252 self.mixer_menu.append(
253 self.new_menu_item(_("_Open..."), self.on_open_cb, "<Control>O")
256 # Recent files sub-menu
257 self.mixer_menu.append(self.create_recent_file_menu())
259 self.mixer_menu.append(self.new_menu_item(_("_Save"), self.on_save_cb, "<Control>S"))
261 if not with_nsm:
262 self.mixer_menu.append(
263 self.new_menu_item(_("Save _As..."), self.on_save_as_cb, "<Shift><Control>S")
266 self.mixer_menu.append(Gtk.SeparatorMenuItem())
267 if with_nsm:
268 self.mixer_menu.append(self.new_menu_item(_("_Hide"), self.nsm_hide_cb, "<Control>W"))
269 else:
270 self.mixer_menu.append(self.new_menu_item(_("_Quit"), self.on_quit_cb, "<Control>Q"))
272 # Edit menu
273 edit_menu = Gtk.Menu()
274 edit_menu_item.set_submenu(edit_menu)
276 self.channel_edit_input_menu_item = self.new_menu_item(
277 _("_Edit Input Channel"), enabled=False
279 edit_menu.append(self.channel_edit_input_menu_item)
280 self.channel_edit_input_menu = Gtk.Menu()
281 self.channel_edit_input_menu_item.set_submenu(self.channel_edit_input_menu)
283 self.channel_edit_output_menu_item = self.new_menu_item(
284 _("E_dit Output Channel"), enabled=False
286 edit_menu.append(self.channel_edit_output_menu_item)
287 self.channel_edit_output_menu = Gtk.Menu()
288 self.channel_edit_output_menu_item.set_submenu(self.channel_edit_output_menu)
290 self.channel_remove_input_menu_item = self.new_menu_item(
291 _("_Remove Input Channel"), enabled=False
293 edit_menu.append(self.channel_remove_input_menu_item)
294 self.channel_remove_input_menu = Gtk.Menu()
295 self.channel_remove_input_menu_item.set_submenu(self.channel_remove_input_menu)
297 self.channel_remove_output_menu_item = self.new_menu_item(
298 _("Re_move Output Channel"), enabled=False
300 edit_menu.append(self.channel_remove_output_menu_item)
301 self.channel_remove_output_menu = Gtk.Menu()
302 self.channel_remove_output_menu_item.set_submenu(self.channel_remove_output_menu)
304 edit_menu.append(Gtk.SeparatorMenuItem())
305 menuitem = self.new_menu_item(_("Shrink Channels"), self.on_shrink_channels_cb,
306 "<Control>minus")
307 self.menu_item_add_accelerator(menuitem, "<Control>KP_Subtract")
308 edit_menu.append(menuitem)
309 menuitem = self.new_menu_item(_("Expand Channels"), self.on_expand_channels_cb,
310 "<Control>plus")
311 self.menu_item_add_accelerator(menuitem, "<Control>KP_Add")
312 edit_menu.append(menuitem)
313 edit_menu.append(Gtk.SeparatorMenuItem())
315 edit_menu.append(self.new_menu_item('Prefader Metering', self.on_prefader_meters_cb,
316 "<Control>M"))
317 edit_menu.append(self.new_menu_item('Postfader Metering', self.on_postfader_meters_cb,
318 "<Shift><Control>M"))
320 edit_menu.append(Gtk.SeparatorMenuItem())
322 edit_menu.append(self.new_menu_item(_("_Clear"), self.on_channels_clear, "<Control>X"))
323 edit_menu.append(Gtk.SeparatorMenuItem())
325 self.preferences_dialog = None
326 edit_menu.append(
327 self.new_menu_item(_("_Preferences"), self.on_preferences_cb, "<Control>P")
330 # Help menu
331 help_menu = Gtk.Menu()
332 help_menu_item.set_submenu(help_menu)
334 help_menu.append(self.new_menu_item(_("_About"), self.on_about, "F1"))
336 # Main panel
337 self.hbox_top = Gtk.HBox()
338 self.vbox_top.pack_start(self.hbox_top, True, True, 0)
340 self.scrolled_window = Gtk.ScrolledWindow()
341 self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
343 self.hbox_inputs = Gtk.Box()
344 self.hbox_inputs.set_spacing(0)
345 self.hbox_inputs.set_border_width(0)
346 self.hbox_top.set_spacing(0)
347 self.hbox_top.set_border_width(0)
348 self.scrolled_window.add(self.hbox_inputs)
349 self.hbox_outputs = Gtk.Box()
350 self.hbox_outputs.set_spacing(0)
351 self.hbox_outputs.set_border_width(0)
352 self.scrolled_output = Gtk.ScrolledWindow()
353 self.scrolled_output.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
354 self.scrolled_output.add(self.hbox_outputs)
355 self.paned = Gtk.HPaned()
356 self.paned.set_wide_handle(True)
357 self.hbox_top.pack_start(self.paned, True, True, 0)
358 self.paned.pack1(self.scrolled_window, True, False)
359 self.paned.pack2(self.scrolled_output, True, False)
361 self.window.connect("destroy", Gtk.main_quit)
362 self.window.connect("delete-event", self.on_delete_event)
364 # ---------------------------------------------------------------------------------------------
365 # Channel creation
367 def add_channel(
368 self,
369 name,
370 stereo=True,
371 direct_output=True,
372 volume_cc=-1,
373 balance_cc=-1,
374 mute_cc=-1,
375 solo_cc=-1,
376 initial_vol=-1,
378 try:
379 channel = InputChannel(
380 self, name, stereo=stereo, direct_output=direct_output, initial_vol=initial_vol
382 self.add_channel_precreated(channel)
383 except Exception:
384 error_dialog(self.window, _("Input channel creation failed."))
385 return
387 channel.assign_midi_ccs(volume_cc, balance_cc, mute_cc, solo_cc)
388 return channel
390 def add_channel_precreated(self, channel):
391 frame = Gtk.Frame()
392 frame.add(channel)
393 self.hbox_inputs.pack_start(frame, False, True, 0)
394 channel.realize()
396 channel_edit_menu_item = Gtk.MenuItem(label=channel.channel_name)
397 self.channel_edit_input_menu.append(channel_edit_menu_item)
398 channel_edit_menu_item.connect("activate", self.on_edit_input_channel, channel)
399 self.channel_edit_input_menu_item.set_sensitive(True)
401 channel_remove_menu_item = Gtk.MenuItem(label=channel.channel_name)
402 self.channel_remove_input_menu.append(channel_remove_menu_item)
403 channel_remove_menu_item.connect("activate", self.on_remove_input_channel, channel)
404 self.channel_remove_input_menu_item.set_sensitive(True)
406 self.channels.append(channel)
408 for outputchannel in self.output_channels:
409 channel.add_control_group(outputchannel)
411 if channel.wants_direct_output:
412 self.add_direct_output(channel)
414 channel.connect("input-channel-order-changed", self.on_input_channel_order_changed)
416 def add_direct_output(self, channel, name=None):
417 # Port names, which are not user-settable are not marked as translatable,
418 # so they are the same regardless of the language setting of the environment
419 # in which a project is loaded.
420 if not name:
421 name = "{channel_name} Out".format(channel_name=channel.channel_name)
422 # create post fader output channel matching the input channel
423 channel.post_fader_output_channel = self.mixer.add_output_channel(
424 name, channel.channel.is_stereo, True
426 channel.post_fader_output_channel.volume = 0
427 channel.post_fader_output_channel.set_solo(channel.channel, True)
429 def add_output_channel(
430 self,
431 name,
432 stereo=True,
433 volume_cc=-1,
434 balance_cc=-1,
435 mute_cc=-1,
436 display_solo_buttons=False,
437 color="#fff",
438 initial_vol=-1,
440 try:
441 channel = OutputChannel(self, name, stereo=stereo, initial_vol=initial_vol)
442 channel.display_solo_buttons = display_solo_buttons
443 channel.color = color
444 self.add_output_channel_precreated(channel)
445 except Exception:
446 error_dialog(self.window, _("Output channel creation failed."))
447 return
449 channel.assign_midi_ccs(volume_cc, balance_cc, mute_cc)
450 return channel
452 def add_output_channel_precreated(self, channel):
453 frame = Gtk.Frame()
454 frame.add(channel)
455 self.hbox_outputs.pack_end(frame, False, True, 0)
456 self.hbox_outputs.reorder_child(frame, 0)
457 channel.realize()
459 channel_edit_menu_item = Gtk.MenuItem(label=channel.channel_name)
460 self.channel_edit_output_menu.append(channel_edit_menu_item)
461 channel_edit_menu_item.connect("activate", self.on_edit_output_channel, channel)
462 self.channel_edit_output_menu_item.set_sensitive(True)
464 channel_remove_menu_item = Gtk.MenuItem(label=channel.channel_name)
465 self.channel_remove_output_menu.append(channel_remove_menu_item)
466 channel_remove_menu_item.connect("activate", self.on_remove_output_channel, channel)
467 self.channel_remove_output_menu_item.set_sensitive(True)
469 self.output_channels.append(channel)
470 channel.connect("output-channel-order-changed", self.on_output_channel_order_changed)
472 # ---------------------------------------------------------------------------------------------
473 # Signal/event handlers
475 # ---------------------------------------------------------------------------------------------
476 # NSM
478 def nsm_react(self):
479 self.nsm_client.reactToMessage()
480 return True
482 def nsm_hide_cb(self, *args):
483 self.window.hide()
484 self.visible = False
485 self.nsm_client.announceGuiVisibility(False)
487 def nsm_show_cb(self):
488 width, height = self.window.get_size()
489 self.window.show_all()
490 self.paned.set_position(self.paned_position / self.width * width)
492 self.visible = True
493 self.nsm_client.announceGuiVisibility(True)
495 def nsm_open_cb(self, path, session_name, client_name):
496 self.create_mixer(client_name, with_nsm=True)
497 self.current_filename = path + ".xml"
498 if isfile(self.current_filename):
499 try:
500 with open(self.current_filename, "r") as fp:
501 self.load_from_xml(fp, from_nsm=True)
502 except Exception as exc:
503 # Re-raise with more meaningful error message
504 raise IOError(
505 _("Error loading project file '{filename}': {msg}").format(
506 filename=self.current_filename, msg=exc
510 def nsm_save_cb(self, path, session_name, client_name):
511 self.current_filename = path + ".xml"
512 with open(self.current_filename, "w") as fp:
513 self.save_to_xml(fp)
515 def nsm_exit_cb(self, path, session_name, client_name):
516 Gtk.main_quit()
518 # ---------------------------------------------------------------------------------------------
519 # POSIX signals
521 def sighandler(self, signum, frame):
522 log.debug("Signal %d received.", signum)
523 if signum == signal.SIGUSR1:
524 GLib.timeout_add(0, self.on_save_cb)
525 elif signum == signal.SIGINT or signum == signal.SIGTERM:
526 GLib.timeout_add(0, self.on_quit_cb)
527 else:
528 log.warning("Unknown signal %d received.", signum)
530 # ---------------------------------------------------------------------------------------------
531 # GTK signals
533 def on_language_changed(self, gui_factory, lang):
534 global translation
535 translation = gettext.translation(
536 __program__, _localedir, languages=[lang] if lang else None, fallback=True
538 translation.install()
540 def on_about(self, *args):
541 about = Gtk.AboutDialog()
542 about.set_name(__program__)
543 about.set_program_name(__program__)
544 about.set_copyright(
545 "Copyright © 2006-2021\n"
546 "Nedko Arnaudov,\n"
547 "Frédéric Péters, Arnout Engelen,\n"
548 "Daniel Sheeler, Christopher Arndt"
550 about.set_license(__license__)
551 about.set_authors(
553 "Nedko Arnaudov <nedko@arnaudov.name>",
554 "Christopher Arndt <chris@chrisarndt.de>",
555 "Arnout Engelen <arnouten@bzzt.net>",
556 "John Hedges <john@drystone.co.uk>",
557 "Olivier Humbert <trebmuh@tuxfamily.org>",
558 "Sarah Mischke <sarah@spooky-online.de>",
559 "Frédéric Péters <fpeters@0d.be>",
560 "Daniel Sheeler <dsheeler@pobox.com>",
561 "Athanasios Silis <athanasios.silis@gmail.com>",
564 about.set_logo_icon_name(__program__)
565 about.set_version(__version__)
566 about.set_website("https://rdio.space/jackmixer/")
567 about.run()
568 about.destroy()
570 def on_delete_event(self, widget, event):
571 if self.nsm_client:
572 self.nsm_hide_cb()
573 return True
575 return self.on_quit_cb(on_delete=True)
577 def add_file_filters(self, dialog):
578 filter_xml = Gtk.FileFilter()
579 filter_xml.set_name(_("XML files"))
580 filter_xml.add_mime_type("text/xml")
581 dialog.add_filter(filter_xml)
582 filter_all = Gtk.FileFilter()
583 filter_all.set_name(_("All files"))
584 filter_all.add_pattern("*")
585 dialog.add_filter(filter_all)
587 def _open_project(self, filename):
588 try:
589 with open(filename, "r") as fp:
590 self.load_from_xml(fp)
591 except Exception as exc:
592 error_dialog(
593 self.window,
594 _("Error loading project file '{filename}': {msg}").format(
595 filename=filename, msg=exc
598 else:
599 self.current_filename = filename
600 return True
602 def on_open_cb(self, *args):
603 dlg = Gtk.FileChooserDialog(
604 title=_("Open project"), parent=self.window, action=Gtk.FileChooserAction.OPEN
606 dlg.add_buttons(
607 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK
609 dlg.set_default_response(Gtk.ResponseType.OK)
611 default_project_path = self.gui_factory.get_default_project_path()
613 if self.current_filename:
614 dlg.set_current_folder(dirname(self.current_filename))
615 else:
616 dlg.set_current_folder(self.last_project_path or default_project_path or os.getcwd())
618 if default_project_path:
619 dlg.add_shortcut_folder(default_project_path)
621 self.add_file_filters(dlg)
623 if dlg.run() == Gtk.ResponseType.OK:
624 filename = dlg.get_filename()
625 if self._open_project(filename):
626 self.recentmanager.add_item("file://" + abspath(filename))
628 dlg.destroy()
630 def on_recent_file_chosen(self, recentchooser):
631 item = recentchooser.get_current_item()
633 if item and item.exists():
634 log.debug("Recent file menu entry selected: %s", item.get_display_name())
635 uri = item.get_uri()
636 if not self._open_project(urlparse(uri).path):
637 self.recentmanager.remove_item(uri)
639 def _save_project(self, filename):
640 with open(filename, "w") as fp:
641 self.save_to_xml(fp)
643 def on_save_cb(self, *args):
644 if not self.current_filename:
645 return self.on_save_as_cb()
647 try:
648 self._save_project(self.current_filename)
649 except Exception as exc:
650 error_dialog(
651 self.window,
652 _("Error saving project file '{filename}': {msg}").format(
653 filename=self.current_filename, msg=exc
657 def on_save_as_cb(self, *args):
658 dlg = Gtk.FileChooserDialog(
659 title=_("Save project"), parent=self.window, action=Gtk.FileChooserAction.SAVE
661 dlg.add_buttons(
662 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK
664 dlg.set_default_response(Gtk.ResponseType.OK)
665 dlg.set_do_overwrite_confirmation(True)
667 default_project_path = self.gui_factory.get_default_project_path()
669 if self.current_filename:
670 dlg.set_filename(self.current_filename)
671 else:
672 dlg.set_current_folder(self.last_project_path or default_project_path or os.getcwd())
673 filename = "{}-{}.xml".format(
674 getpass.getuser(), datetime.datetime.now().strftime("%Y%m%d-%H%M")
676 dlg.set_current_name(filename)
678 if default_project_path:
679 dlg.add_shortcut_folder(default_project_path)
681 self.add_file_filters(dlg)
683 if dlg.run() == Gtk.ResponseType.OK:
684 save_path = dlg.get_filename()
685 save_dir = dirname(save_path)
686 if isdir(save_dir):
687 self.last_project_path = save_dir
689 filename = dlg.get_filename()
690 try:
691 self._save_project(filename)
692 except Exception as exc:
693 error_dialog(
694 self.window,
695 _("Error saving project file '{filename}': {msg}").format(
696 filename=filename, msg=exc
699 else:
700 self.current_filename = filename
701 self.recentmanager.add_item("file://" + abspath(filename))
703 dlg.destroy()
705 def on_quit_cb(self, *args, on_delete=False):
706 if not self.nsm_client and self.gui_factory.get_confirm_quit():
707 dlg = Gtk.MessageDialog(
708 parent=self.window,
709 message_type=Gtk.MessageType.QUESTION,
710 buttons=Gtk.ButtonsType.NONE,
712 dlg.set_markup(_("<b>Quit application?</b>"))
713 dlg.format_secondary_markup(
715 "All jack_mixer ports will be closed and connections lost,"
716 "\nstopping all sound going through jack_mixer.\n\n"
717 "Are you sure?"
720 dlg.add_buttons(
721 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_QUIT, Gtk.ResponseType.OK
723 response = dlg.run()
724 dlg.destroy()
725 if response != Gtk.ResponseType.OK:
726 return on_delete
728 Gtk.main_quit()
730 def on_shrink_channels_cb(self, widget):
731 for channel in self.channels + self.output_channels:
732 channel.narrow()
734 def on_expand_channels_cb(self, widget):
735 for channel in self.channels + self.output_channels:
736 channel.widen()
738 def on_prefader_meters_cb(self, widget):
739 for channel in self.channels + self.output_channels:
740 channel.use_prefader_metering()
742 def on_postfader_meters_cb(self, widget):
743 for channel in self.channels + self.output_channels:
744 channel.use_prefader_metering(False)
746 def on_midi_behavior_mode_changed(self, gui_factory, value):
747 self.mixer.midi_behavior_mode = value
749 def on_preferences_cb(self, widget):
750 if not self.preferences_dialog:
751 self.preferences_dialog = PreferencesDialog(self)
752 self.preferences_dialog.show()
753 self.preferences_dialog.present()
755 def on_add_channel(self, inout="input", default_name="Input"):
756 dialog = getattr(self, "_add_{}_dialog".format(inout), None)
757 values = getattr(self, "_add_{}_values".format(inout), {})
759 if dialog is None:
760 cls = NewInputChannelDialog if inout == "input" else NewOutputChannelDialog
761 dialog = cls(app=self)
762 setattr(self, "_add_{}_dialog".format(inout), dialog)
764 names = {
765 ch.channel_name for ch in (self.channels if inout == "input" else self.output_channels)
767 values.setdefault("name", default_name)
768 while True:
769 if values["name"] in names:
770 values["name"] = add_number_suffix(values["name"])
771 else:
772 break
774 dialog.fill_ui(**values)
775 dialog.set_transient_for(self.window)
776 dialog.show()
777 ret = dialog.run()
778 dialog.hide()
780 if ret == Gtk.ResponseType.OK:
781 result = dialog.get_result()
782 setattr(self, "_add_{}_values".format(inout), result)
783 (self.add_channel if inout == "input" else self.add_output_channel)(**result)
784 if self.visible or self.nsm_client is None:
785 self.window.show_all()
787 def on_add_input_channel(self, widget):
788 return self.on_add_channel("input", _("Input"))
790 def on_add_output_channel(self, widget):
791 return self.on_add_channel("output", _("Output"))
793 def on_edit_input_channel(self, widget, channel):
794 log.debug('Editing input channel "%s".', channel.channel_name)
795 channel.on_channel_properties()
797 def on_remove_input_channel(self, widget, channel):
798 log.debug('Removing input channel "%s".', channel.channel_name)
800 def remove_channel_edit_input_menuitem_by_label(widget, label):
801 if widget.get_label() == label:
802 self.channel_edit_input_menu.remove(widget)
804 self.channel_remove_input_menu.remove(widget)
805 self.channel_edit_input_menu.foreach(
806 remove_channel_edit_input_menuitem_by_label, channel.channel_name
809 if self.monitored_channel is channel:
810 channel.monitor_button.set_active(False)
812 for i in range(len(self.channels)):
813 if self.channels[i] is channel:
814 channel.unrealize()
815 del self.channels[i]
816 self.hbox_inputs.remove(channel.get_parent())
817 break
819 if not self.channels:
820 self.channel_edit_input_menu_item.set_sensitive(False)
821 self.channel_remove_input_menu_item.set_sensitive(False)
823 def on_edit_output_channel(self, widget, channel):
824 log.debug('Editing output channel "%s".', channel.channel_name)
825 channel.on_channel_properties()
827 def on_remove_output_channel(self, widget, channel):
828 log.debug('Removing output channel "%s".', channel.channel_name)
830 def remove_channel_edit_output_menuitem_by_label(widget, label):
831 if widget.get_label() == label:
832 self.channel_edit_output_menu.remove(widget)
834 self.channel_remove_output_menu.remove(widget)
835 self.channel_edit_output_menu.foreach(
836 remove_channel_edit_output_menuitem_by_label, channel.channel_name
839 if self.monitored_channel is channel:
840 channel.monitor_button.set_active(False)
842 for i in range(len(self.channels)):
843 if self.output_channels[i] is channel:
844 channel.unrealize()
845 del self.output_channels[i]
846 self.hbox_outputs.remove(channel.get_parent())
847 break
849 if not self.output_channels:
850 self.channel_edit_output_menu_item.set_sensitive(False)
851 self.channel_remove_output_menu_item.set_sensitive(False)
853 def on_channel_rename(self, oldname, newname):
854 def rename_channels(container, parameters):
855 if container.get_label() == parameters["oldname"]:
856 container.set_label(parameters["newname"])
858 rename_parameters = {"oldname": oldname, "newname": newname}
859 self.channel_edit_input_menu.foreach(rename_channels, rename_parameters)
860 self.channel_edit_output_menu.foreach(rename_channels, rename_parameters)
861 self.channel_remove_input_menu.foreach(rename_channels, rename_parameters)
862 self.channel_remove_output_menu.foreach(rename_channels, rename_parameters)
863 log.debug('Renaming channel from "%s" to "%s".', oldname, newname)
865 def reorder_menu_item(self, menu, source_label, dest_label):
866 pos = -1
867 source_item = None
868 for i, menuitem in enumerate(menu.get_children()):
869 label = menuitem.get_label()
870 if label == source_label:
871 source_item = menuitem
872 elif label == dest_label:
873 pos = i
875 if pos != -1 and source_item is not None:
876 menu.reorder_child(source_item, pos)
878 def reorder_channels(self, container, source_name, dest_name, reverse=False):
879 frames = container.get_children()
881 for frame in frames:
882 if source_name == frame.get_child().channel_name:
883 source_frame = frame
884 break
886 if reverse:
887 frames.reverse()
889 for pos, frame in enumerate(frames):
890 if dest_name == frame.get_child().channel_name:
891 container.reorder_child(source_frame, pos)
892 break
894 def on_input_channel_order_changed(self, widget, source_name, dest_name):
895 self.channels.clear()
896 self.reorder_channels(self.hbox_inputs, source_name, dest_name)
898 for frame in self.hbox_inputs.get_children():
899 self.channels.append(frame.get_child())
901 for menu in (self.channel_edit_input_menu, self.channel_remove_input_menu):
902 self.reorder_menu_item(menu, source_name, dest_name)
904 def on_output_channel_order_changed(self, widget, source_name, dest_name):
905 self.output_channels.clear()
906 self.reorder_channels(self.hbox_outputs, source_name, dest_name, reverse=True)
908 for frame in self.hbox_outputs.get_children():
909 self.output_channels.append(frame.get_child())
911 for menu in (self.channel_edit_output_menu, self.channel_remove_output_menu):
912 self.reorder_menu_item(menu, source_name, dest_name)
914 def on_channels_clear(self, widget):
915 dlg = Gtk.MessageDialog(
916 parent=self.window,
917 modal=True,
918 message_type=Gtk.MessageType.WARNING,
919 text=_("Are you sure you want to clear all channels?"),
920 buttons=Gtk.ButtonsType.OK_CANCEL,
923 if not widget or dlg.run() == Gtk.ResponseType.OK:
924 for channel in self.output_channels:
925 channel.unrealize()
926 self.hbox_outputs.remove(channel.get_parent())
927 for channel in self.channels:
928 channel.unrealize()
929 self.hbox_inputs.remove(channel.get_parent())
930 self.channels = []
931 self.output_channels = []
932 self.channel_edit_input_menu = Gtk.Menu()
933 self.channel_edit_input_menu_item.set_submenu(self.channel_edit_input_menu)
934 self.channel_edit_input_menu_item.set_sensitive(False)
935 self.channel_remove_input_menu = Gtk.Menu()
936 self.channel_remove_input_menu_item.set_submenu(self.channel_remove_input_menu)
937 self.channel_remove_input_menu_item.set_sensitive(False)
938 self.channel_edit_output_menu = Gtk.Menu()
939 self.channel_edit_output_menu_item.set_submenu(self.channel_edit_output_menu)
940 self.channel_edit_output_menu_item.set_sensitive(False)
941 self.channel_remove_output_menu = Gtk.Menu()
942 self.channel_remove_output_menu_item.set_submenu(self.channel_remove_output_menu)
943 self.channel_remove_output_menu_item.set_sensitive(False)
945 # Force save-as dialog on next save
946 self.current_filename = None
948 dlg.destroy()
950 def on_default_meter_scale_changed(self, sender, newscale):
951 if isinstance(newscale, (scale.K14, scale.K20)):
952 self.mixer.kmetering = True
953 else:
954 self.mixer.kmetering = False
956 def on_meter_refresh_period_milliseconds_changed(self, sender, value):
957 if self.meter_refresh_timer_id is not None:
958 GLib.source_remove(self.meter_refresh_timer_id)
959 self.meter_refresh_period = value
960 self.meter_refresh_timer_id = GLib.timeout_add(self.meter_refresh_period, self.read_meters)
962 def read_meters(self):
963 for channel in self.channels:
964 channel.read_meter()
965 for channel in self.output_channels:
966 channel.read_meter()
967 return True
969 def midi_events_check(self):
970 for channel in self.channels + self.output_channels:
971 channel.midi_events_check()
972 return True
974 def get_monitored_channel(self):
975 return self._monitored_channel
977 def set_monitored_channel(self, channel):
978 if channel == self._monitored_channel:
979 return
980 self._monitored_channel = channel
981 if channel is None:
982 self.monitor_channel.out_mute = True
983 elif isinstance(channel, InputChannel):
984 # reset all solo/mute settings
985 for in_channel in self.channels:
986 self.monitor_channel.set_solo(in_channel.channel, False)
987 self.monitor_channel.set_muted(in_channel.channel, False)
988 self.monitor_channel.set_solo(channel.channel, True)
989 self.monitor_channel.prefader = True
990 self.monitor_channel.out_mute = False
991 else:
992 self.monitor_channel.prefader = False
993 self.monitor_channel.out_mute = False
995 if channel:
996 self.update_monitor(channel)
998 monitored_channel = property(get_monitored_channel, set_monitored_channel)
1000 def update_monitor(self, channel):
1001 if self._monitored_channel is not channel:
1002 return
1003 self.monitor_channel.volume = channel.channel.volume
1004 self.monitor_channel.balance = channel.channel.balance
1005 if isinstance(self.monitored_channel, OutputChannel):
1006 # sync solo/muted channels
1007 for input_channel in self.channels:
1008 self.monitor_channel.set_solo(
1009 input_channel.channel, channel.channel.is_solo(input_channel.channel)
1011 self.monitor_channel.set_muted(
1012 input_channel.channel, channel.channel.is_muted(input_channel.channel)
1015 def get_input_channel_by_name(self, name):
1016 for input_channel in self.channels:
1017 if input_channel.channel.name == name:
1018 return input_channel
1019 return None
1021 # ---------------------------------------------------------------------------------------------
1022 # Mixer project (de-)serialization and file handling
1024 def save_to_xml(self, file):
1025 log.debug("Saving to XML...")
1026 b = XmlSerialization()
1027 s = Serializator()
1028 s.serialize(self, b)
1029 b.save(file)
1031 def load_from_xml(self, file, silence_errors=False, from_nsm=False):
1032 log.debug("Loading from XML...")
1033 self.unserialized_channels = []
1034 b = XmlSerialization()
1035 try:
1036 b.load(file, self.serialization_name())
1037 except: # noqa: E722
1038 if silence_errors:
1039 return
1040 raise
1042 self.on_channels_clear(None)
1043 s = Serializator()
1044 s.unserialize(self, b)
1045 for channel in self.unserialized_channels:
1046 if isinstance(channel, InputChannel):
1047 if self._init_solo_channels and channel.channel_name in self._init_solo_channels:
1048 channel.solo = True
1049 self.add_channel_precreated(channel)
1050 self._init_solo_channels = None
1051 for channel in self.unserialized_channels:
1052 if isinstance(channel, OutputChannel):
1053 self.add_output_channel_precreated(channel)
1054 del self.unserialized_channels
1055 width, height = self.window.get_size()
1056 if self.visible or not from_nsm:
1057 self.window.show_all()
1059 if self.output_channels:
1060 self.output_channels[-1].volume_digits.select_region(0, 0)
1061 self.output_channels[-1].slider.grab_focus()
1062 elif self.channels:
1063 self.channels[-1].volume_digits.select_region(0, 0)
1064 self.channels[-1].volume_digits.grab_focus()
1066 self.paned.set_position(self.paned_position / self.width * width)
1067 self.window.resize(self.width, self.height)
1069 def serialize(self, object_backend):
1070 width, height = self.window.get_size()
1071 object_backend.add_property("geometry", "%sx%s" % (width, height))
1072 pos = self.paned.get_position()
1073 object_backend.add_property("paned_position", "%s" % pos)
1074 solo_channels = []
1075 for input_channel in self.channels:
1076 if input_channel.channel.solo:
1077 solo_channels.append(input_channel)
1078 if solo_channels:
1079 object_backend.add_property(
1080 "solo_channels", "|".join([x.channel.name for x in solo_channels])
1082 object_backend.add_property("visible", "%s" % str(self.visible))
1084 def unserialize_property(self, name, value):
1085 if name == "geometry":
1086 width, height = value.split("x")
1087 self.width = int(width)
1088 self.height = int(height)
1089 return True
1090 elif name == "solo_channels":
1091 self._init_solo_channels = value.split("|")
1092 return True
1093 elif name == "visible":
1094 self.visible = value == "True"
1095 return True
1096 elif name == "paned_position":
1097 self.paned_position = int(value)
1098 return True
1100 return False
1102 def unserialize_child(self, name):
1103 if name == InputChannel.serialization_name():
1104 channel = InputChannel(self, "", True)
1105 self.unserialized_channels.append(channel)
1106 return channel
1107 elif name == OutputChannel.serialization_name():
1108 channel = OutputChannel(self, "", True)
1109 self.unserialized_channels.append(channel)
1110 return channel
1111 elif name == gui.Factory.serialization_name():
1112 return self.gui_factory
1114 def serialization_get_childs(self):
1115 """Get child objects that require and support serialization."""
1116 childs = self.channels[:] + self.output_channels[:] + [self.gui_factory]
1117 return childs
1119 def serialization_name(self):
1120 return __program__
1122 # ---------------------------------------------------------------------------------------------
1123 # Main program loop
1125 def main(self):
1126 if not self.mixer:
1127 return
1129 if self.visible or self.nsm_client is None:
1130 width, height = self.window.get_size()
1131 self.window.show_all()
1132 if hasattr(self, "paned_position"):
1133 self.paned.set_position(self.paned_position / self.width * width)
1135 signal.signal(signal.SIGUSR1, self.sighandler)
1136 signal.signal(signal.SIGTERM, self.sighandler)
1137 signal.signal(signal.SIGINT, self.sighandler)
1138 signal.signal(signal.SIGHUP, signal.SIG_IGN)
1140 Gtk.main()
1143 def error_dialog(parent, msg, *args, **kw):
1144 if kw.get("debug"):
1145 log.exception(msg.format(*args))
1146 err = Gtk.MessageDialog(
1147 parent=parent,
1148 modal=True,
1149 destroy_with_parent=True,
1150 message_type=Gtk.MessageType.ERROR,
1151 buttons=Gtk.ButtonsType.OK,
1152 text=msg.format(*args),
1154 err.run()
1155 err.destroy()
1158 def main():
1159 parser = argparse.ArgumentParser(prog=__program__, description=_(__doc__.splitlines()[0]))
1160 parser.add_argument(
1161 "-c",
1162 "--config",
1163 metavar=_("FILE"),
1164 help=_("load mixer project configuration from FILE")
1166 parser.add_argument(
1167 "-d",
1168 "--debug",
1169 action="store_true",
1170 default="JACK_MIXER_DEBUG" in os.environ,
1171 help=_("enable debug logging messages"),
1173 parser.add_argument(
1174 "client_name",
1175 metavar=_("NAME"),
1176 nargs="?",
1177 default=__program__,
1178 help=_("set JACK client name (default: %(default)s)"),
1180 args = parser.parse_args()
1182 logging.basicConfig(
1183 level=logging.DEBUG if args.debug else logging.INFO, format="%(levelname)s: %(message)s"
1186 try:
1187 mixer = JackMixer(args.client_name)
1188 except Exception as e:
1189 error_dialog(None, _("Mixer creation failed:\n\n{}"), e, debug=args.debug)
1190 sys.exit(1)
1192 if not mixer.nsm_client and args.config:
1193 try:
1194 with open(args.config) as fp:
1195 mixer.load_from_xml(fp)
1196 except Exception as exc:
1197 error_dialog(
1198 mixer.window,
1199 _("Error loading project file '{filename}': {msg}").format(
1200 filename=args.config, msg=exc
1203 else:
1204 mixer.current_filename = args.config
1206 mixer.window.set_default_size(
1207 60 * (1 + len(mixer.channels) + len(mixer.output_channels)), 300
1210 mixer.main()
1211 mixer.cleanup()
1214 if __name__ == "__main__":
1215 main()