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.
18 from os
.path
import abspath
, dirname
, isdir
, isfile
, join
19 from urllib
.parse
import urlparse
23 gi
.require_version("Gtk", "3.0")
24 from gi
.repository
import Gtk
25 from gi
.repository
import GLib
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)
47 log
= logging
.getLogger(__program__
)
48 __doc__
= _("A multi-channel audio mixer application for the JACK Audio Connection Kit.")
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
65 # Hack argparse and delay its import to get it to use our translations
66 gettext
.gettext
, gettext
.ngettext
= translation
.gettext
, translation
.ngettext
70 def add_number_suffix(s
):
72 return str(int(match
.group(0)) + 1)
74 new_s
= re
.sub(r
"(\d+)\s*$", inc
, s
)
81 class JackMixer(SerializedObject
):
83 # scales suitable as meter scales
89 scale
.IEC268Minimalistic(),
92 # scales suitable as volume slider scales
93 slider_scales
= [scale
.Linear30dB(), scale
.Linear70dB()]
95 def __init__(self
, client_name
=__program__
):
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
)
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
)
135 GLib
.timeout_add(200, self
.nsm_react
)
138 log
.debug("Cleaning jack_mixer.")
142 for channel
in self
.channels
:
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
)
154 menuitem
.connect("activate", callback
)
156 self
.menu_item_add_accelerator(menuitem
, accel
)
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
):
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
)
191 def create_ui(self
, with_nsm
):
193 self
.output_channels
= []
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
)
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(
246 _("New Output _Channel"), self
.on_add_output_channel
, "<Shift><Control>N"
250 self
.mixer_menu
.append(Gtk
.SeparatorMenuItem())
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"))
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())
268 self
.mixer_menu
.append(self
.new_menu_item(_("_Hide"), self
.nsm_hide_cb
, "<Control>W"))
270 self
.mixer_menu
.append(self
.new_menu_item(_("_Quit"), self
.on_quit_cb
, "<Control>Q"))
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
,
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
,
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
,
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
327 self
.new_menu_item(_("_Preferences"), self
.on_preferences_cb
, "<Control>P")
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"))
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 # ---------------------------------------------------------------------------------------------
379 channel
= InputChannel(
380 self
, name
, stereo
=stereo
, direct_output
=direct_output
, initial_vol
=initial_vol
382 self
.add_channel_precreated(channel
)
384 error_dialog(self
.window
, _("Input channel creation failed."))
387 channel
.assign_midi_ccs(volume_cc
, balance_cc
, mute_cc
, solo_cc
)
390 def add_channel_precreated(self
, channel
):
393 self
.hbox_inputs
.pack_start(frame
, False, True, 0)
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.
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(
436 display_solo_buttons
=False,
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
)
446 error_dialog(self
.window
, _("Output channel creation failed."))
449 channel
.assign_midi_ccs(volume_cc
, balance_cc
, mute_cc
)
452 def add_output_channel_precreated(self
, channel
):
455 self
.hbox_outputs
.pack_end(frame
, False, True, 0)
456 self
.hbox_outputs
.reorder_child(frame
, 0)
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 # ---------------------------------------------------------------------------------------------
479 self
.nsm_client
.reactToMessage()
482 def nsm_hide_cb(self
, *args
):
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
)
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
):
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
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
:
515 def nsm_exit_cb(self
, path
, session_name
, client_name
):
518 # ---------------------------------------------------------------------------------------------
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
)
528 log
.warning("Unknown signal %d received.", signum
)
530 # ---------------------------------------------------------------------------------------------
533 def on_language_changed(self
, gui_factory
, lang
):
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__
)
545 "Copyright © 2006-2021\n"
547 "Frédéric Péters, Arnout Engelen,\n"
548 "Daniel Sheeler, Christopher Arndt"
550 about
.set_license(__license__
)
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/")
570 def on_delete_event(self
, widget
, event
):
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
):
589 with
open(filename
, "r") as fp
:
590 self
.load_from_xml(fp
)
591 except Exception as exc
:
594 _("Error loading project file '{filename}': {msg}").format(
595 filename
=filename
, msg
=exc
599 self
.current_filename
= filename
602 def on_open_cb(self
, *args
):
603 dlg
= Gtk
.FileChooserDialog(
604 title
=_("Open project"), parent
=self
.window
, action
=Gtk
.FileChooserAction
.OPEN
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
))
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
))
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())
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
:
643 def on_save_cb(self
, *args
):
644 if not self
.current_filename
:
645 return self
.on_save_as_cb()
648 self
._save
_project
(self
.current_filename
)
649 except Exception as exc
:
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
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
)
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
)
687 self
.last_project_path
= save_dir
689 filename
= dlg
.get_filename()
691 self
._save
_project
(filename
)
692 except Exception as exc
:
695 _("Error saving project file '{filename}': {msg}").format(
696 filename
=filename
, msg
=exc
700 self
.current_filename
= filename
701 self
.recentmanager
.add_item("file://" + abspath(filename
))
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(
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"
721 Gtk
.STOCK_CANCEL
, Gtk
.ResponseType
.CANCEL
, Gtk
.STOCK_QUIT
, Gtk
.ResponseType
.OK
725 if response
!= Gtk
.ResponseType
.OK
:
730 def on_shrink_channels_cb(self
, widget
):
731 for channel
in self
.channels
+ self
.output_channels
:
734 def on_expand_channels_cb(self
, widget
):
735 for channel
in self
.channels
+ self
.output_channels
:
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
), {})
760 cls
= NewInputChannelDialog
if inout
== "input" else NewOutputChannelDialog
761 dialog
= cls(app
=self
)
762 setattr(self
, "_add_{}_dialog".format(inout
), dialog
)
765 ch
.channel_name
for ch
in (self
.channels
if inout
== "input" else self
.output_channels
)
767 values
.setdefault("name", default_name
)
769 if values
["name"] in names
:
770 values
["name"] = add_number_suffix(values
["name"])
774 dialog
.fill_ui(**values
)
775 dialog
.set_transient_for(self
.window
)
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
:
816 self
.hbox_inputs
.remove(channel
.get_parent())
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
:
845 del self
.output_channels
[i
]
846 self
.hbox_outputs
.remove(channel
.get_parent())
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
):
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
:
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()
882 if source_name
== frame
.get_child().channel_name
:
889 for pos
, frame
in enumerate(frames
):
890 if dest_name
== frame
.get_child().channel_name
:
891 container
.reorder_child(source_frame
, pos
)
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(
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
:
926 self
.hbox_outputs
.remove(channel
.get_parent())
927 for channel
in self
.channels
:
929 self
.hbox_inputs
.remove(channel
.get_parent())
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
950 def on_default_meter_scale_changed(self
, sender
, newscale
):
951 if isinstance(newscale
, (scale
.K14
, scale
.K20
)):
952 self
.mixer
.kmetering
= True
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
:
965 for channel
in self
.output_channels
:
969 def midi_events_check(self
):
970 for channel
in self
.channels
+ self
.output_channels
:
971 channel
.midi_events_check()
974 def get_monitored_channel(self
):
975 return self
._monitored
_channel
977 def set_monitored_channel(self
, channel
):
978 if channel
== self
._monitored
_channel
:
980 self
._monitored
_channel
= channel
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
992 self
.monitor_channel
.prefader
= False
993 self
.monitor_channel
.out_mute
= False
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
:
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
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()
1028 s
.serialize(self
, b
)
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()
1036 b
.load(file, self
.serialization_name())
1037 except: # noqa: E722
1042 self
.on_channels_clear(None)
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
:
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()
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
)
1075 for input_channel
in self
.channels
:
1076 if input_channel
.channel
.solo
:
1077 solo_channels
.append(input_channel
)
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
)
1090 elif name
== "solo_channels":
1091 self
._init
_solo
_channels
= value
.split("|")
1093 elif name
== "visible":
1094 self
.visible
= value
== "True"
1096 elif name
== "paned_position":
1097 self
.paned_position
= int(value
)
1102 def unserialize_child(self
, name
):
1103 if name
== InputChannel
.serialization_name():
1104 channel
= InputChannel(self
, "", True)
1105 self
.unserialized_channels
.append(channel
)
1107 elif name
== OutputChannel
.serialization_name():
1108 channel
= OutputChannel(self
, "", True)
1109 self
.unserialized_channels
.append(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
]
1119 def serialization_name(self
):
1122 # ---------------------------------------------------------------------------------------------
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
)
1143 def error_dialog(parent
, msg
, *args
, **kw
):
1145 log
.exception(msg
.format(*args
))
1146 err
= Gtk
.MessageDialog(
1149 destroy_with_parent
=True,
1150 message_type
=Gtk
.MessageType
.ERROR
,
1151 buttons
=Gtk
.ButtonsType
.OK
,
1152 text
=msg
.format(*args
),
1159 parser
= argparse
.ArgumentParser(prog
=__program__
, description
=_(__doc__
.splitlines()[0]))
1160 parser
.add_argument(
1164 help=_("load mixer project configuration from FILE")
1166 parser
.add_argument(
1169 action
="store_true",
1170 default
="JACK_MIXER_DEBUG" in os
.environ
,
1171 help=_("enable debug logging messages"),
1173 parser
.add_argument(
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"
1187 mixer
= JackMixer(args
.client_name
)
1188 except Exception as e
:
1189 error_dialog(None, _("Mixer creation failed:\n\n{}"), e
, debug
=args
.debug
)
1192 if not mixer
.nsm_client
and args
.config
:
1194 with
open(args
.config
) as fp
:
1195 mixer
.load_from_xml(fp
)
1196 except Exception as exc
:
1199 _("Error loading project file '{filename}': {msg}").format(
1200 filename
=args
.config
, msg
=exc
1204 mixer
.current_filename
= args
.config
1206 mixer
.window
.set_default_size(
1207 60 * (1 + len(mixer
.channels
) + len(mixer
.output_channels
)), 300
1214 if __name__
== "__main__":