Update channel edit/remove menu order when channels are re-ordered
[jack_mixer.git] / jack_mixer.py
blob09523aa1f7ec5d43620de9a0a76ab3e39576cc70
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 def filter_func(item):
153 return item.mime_type in ("text/xml", "application/xml") and (
154 "jack_mixer.py" in item.applications or "jack_mixer" in item.applications
157 filter_flags = Gtk.RecentFilterFlags.MIME_TYPE | Gtk.RecentFilterFlags.APPLICATION
158 recentfilter = Gtk.RecentFilter()
159 recentfilter.set_name("jack_mixer XML files")
160 recentfilter.add_custom(filter_flags, filter_func)
162 recentchooser = Gtk.RecentChooserMenu.new_for_manager(self.recentmanager)
163 recentchooser.set_sort_type(Gtk.RecentSortType.MRU)
164 recentchooser.set_local_only(True)
165 recentchooser.set_limit(10)
166 recentchooser.set_show_icons(True)
167 recentchooser.set_show_numbers(True)
168 recentchooser.set_show_tips(True)
169 recentchooser.add_filter(recentfilter)
170 recentchooser.connect("item-activated", self.on_recent_file_chosen)
172 recentmenu = Gtk.MenuItem.new_with_mnemonic("_Recent Projects")
173 recentmenu.set_submenu(recentchooser)
174 return recentmenu
176 def create_ui(self, with_nsm):
177 self.channels = []
178 self.output_channels = []
179 load_css_styles()
181 # Main window
182 self.width = 420
183 self.height = 420
184 self.paned_position = 210
185 self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
186 self.window.set_icon_name("jack_mixer")
187 self.window.set_default_size(self.width, self.height)
189 self.gui_factory = gui.Factory(self.window, self.meter_scales, self.slider_scales)
190 self.gui_factory.connect("midi-behavior-mode-changed", self.on_midi_behavior_mode_changed)
191 self.gui_factory.emit_midi_behavior_mode()
193 # Recent files manager
194 self.recentmanager = Gtk.RecentManager.get_default()
196 self.vbox_top = Gtk.VBox()
197 self.window.add(self.vbox_top)
199 self.menu_accelgroup = Gtk.AccelGroup()
200 self.window.add_accel_group(self.menu_accelgroup)
202 # Main Menu
203 self.menubar = Gtk.MenuBar()
204 self.vbox_top.pack_start(self.menubar, False, True, 0)
206 mixer_menu_item = Gtk.MenuItem.new_with_mnemonic("_Mixer")
207 self.menubar.append(mixer_menu_item)
208 edit_menu_item = Gtk.MenuItem.new_with_mnemonic("_Edit")
209 self.menubar.append(edit_menu_item)
210 help_menu_item = Gtk.MenuItem.new_with_mnemonic("_Help")
211 self.menubar.append(help_menu_item)
213 # Mixer (and File) menu
214 self.mixer_menu = Gtk.Menu()
215 mixer_menu_item.set_submenu(self.mixer_menu)
217 self.mixer_menu.append(
218 self.new_menu_item("New _Input Channel", self.on_add_input_channel, "<Control>N")
220 self.mixer_menu.append(
221 self.new_menu_item(
222 "New Output _Channel", self.on_add_output_channel, "<Shift><Control>N"
226 self.mixer_menu.append(Gtk.SeparatorMenuItem())
227 if not with_nsm:
228 self.mixer_menu.append(self.new_menu_item("_Open...", self.on_open_cb, "<Control>O"))
230 # Recent files sub-menu
231 self.mixer_menu.append(self.create_recent_file_menu())
233 self.mixer_menu.append(self.new_menu_item("_Save", self.on_save_cb, "<Control>S"))
235 if not with_nsm:
236 self.mixer_menu.append(
237 self.new_menu_item("Save _As...", self.on_save_as_cb, "<Shift><Control>S")
240 self.mixer_menu.append(Gtk.SeparatorMenuItem())
241 if with_nsm:
242 self.mixer_menu.append(self.new_menu_item("_Hide", self.nsm_hide_cb, "<Control>W"))
243 else:
244 self.mixer_menu.append(self.new_menu_item("_Quit", self.on_quit_cb, "<Control>Q"))
246 # Edit menu
247 edit_menu = Gtk.Menu()
248 edit_menu_item.set_submenu(edit_menu)
250 self.channel_edit_input_menu_item = self.new_menu_item(
251 "_Edit Input Channel", enabled=False
253 edit_menu.append(self.channel_edit_input_menu_item)
254 self.channel_edit_input_menu = Gtk.Menu()
255 self.channel_edit_input_menu_item.set_submenu(self.channel_edit_input_menu)
257 self.channel_edit_output_menu_item = self.new_menu_item(
258 "E_dit Output Channel", enabled=False
260 edit_menu.append(self.channel_edit_output_menu_item)
261 self.channel_edit_output_menu = Gtk.Menu()
262 self.channel_edit_output_menu_item.set_submenu(self.channel_edit_output_menu)
264 self.channel_remove_input_menu_item = self.new_menu_item(
265 "_Remove Input Channel", enabled=False
267 edit_menu.append(self.channel_remove_input_menu_item)
268 self.channel_remove_input_menu = Gtk.Menu()
269 self.channel_remove_input_menu_item.set_submenu(self.channel_remove_input_menu)
271 self.channel_remove_output_menu_item = self.new_menu_item(
272 "Re_move Output Channel", enabled=False
274 edit_menu.append(self.channel_remove_output_menu_item)
275 self.channel_remove_output_menu = Gtk.Menu()
276 self.channel_remove_output_menu_item.set_submenu(self.channel_remove_output_menu)
278 edit_menu.append(Gtk.SeparatorMenuItem())
279 edit_menu.append(
280 self.new_menu_item("Shrink Channels", self.on_shrink_channels_cb, "<Control>minus")
282 edit_menu.append(
283 self.new_menu_item("Expand Channels", self.on_expand_channels_cb, "<Control>plus")
285 edit_menu.append(Gtk.SeparatorMenuItem())
287 edit_menu.append(self.new_menu_item("_Clear", self.on_channels_clear, "<Control>X"))
288 edit_menu.append(Gtk.SeparatorMenuItem())
290 self.preferences_dialog = None
291 edit_menu.append(self.new_menu_item("_Preferences", self.on_preferences_cb, "<Control>P"))
293 # Help menu
294 help_menu = Gtk.Menu()
295 help_menu_item.set_submenu(help_menu)
297 help_menu.append(self.new_menu_item("_About", self.on_about, "F1"))
299 # Main panel
300 self.hbox_top = Gtk.HBox()
301 self.vbox_top.pack_start(self.hbox_top, True, True, 0)
303 self.scrolled_window = Gtk.ScrolledWindow()
304 self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
306 self.hbox_inputs = Gtk.Box()
307 self.hbox_inputs.set_spacing(0)
308 self.hbox_inputs.set_border_width(0)
309 self.hbox_top.set_spacing(0)
310 self.hbox_top.set_border_width(0)
311 self.scrolled_window.add(self.hbox_inputs)
312 self.hbox_outputs = Gtk.Box()
313 self.hbox_outputs.set_spacing(0)
314 self.hbox_outputs.set_border_width(0)
315 self.scrolled_output = Gtk.ScrolledWindow()
316 self.scrolled_output.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
317 self.scrolled_output.add(self.hbox_outputs)
318 self.paned = Gtk.HPaned()
319 self.paned.set_wide_handle(True)
320 self.hbox_top.pack_start(self.paned, True, True, 0)
321 self.paned.pack1(self.scrolled_window, True, False)
322 self.paned.pack2(self.scrolled_output, True, False)
324 self.window.connect("destroy", Gtk.main_quit)
325 self.window.connect("delete-event", self.on_delete_event)
327 # ---------------------------------------------------------------------------------------------
328 # Channel creation
330 def add_channel(self, name, stereo, volume_cc, balance_cc, mute_cc, solo_cc, value):
331 try:
332 channel = InputChannel(self, name, stereo, value)
333 self.add_channel_precreated(channel)
334 except Exception:
335 error_dialog(self.window, "Input channel creation failed.")
336 return
338 channel.assign_midi_ccs(volume_cc, balance_cc, mute_cc, solo_cc)
339 return channel
341 def add_channel_precreated(self, channel):
342 frame = Gtk.Frame()
343 frame.add(channel)
344 self.hbox_inputs.pack_start(frame, False, True, 0)
345 channel.realize()
347 channel_edit_menu_item = Gtk.MenuItem(label=channel.channel_name)
348 self.channel_edit_input_menu.append(channel_edit_menu_item)
349 channel_edit_menu_item.connect("activate", self.on_edit_input_channel, channel)
350 self.channel_edit_input_menu_item.set_sensitive(True)
352 channel_remove_menu_item = Gtk.MenuItem(label=channel.channel_name)
353 self.channel_remove_input_menu.append(channel_remove_menu_item)
354 channel_remove_menu_item.connect("activate", self.on_remove_input_channel, channel)
355 self.channel_remove_input_menu_item.set_sensitive(True)
357 self.channels.append(channel)
359 for outputchannel in self.output_channels:
360 channel.add_control_group(outputchannel)
362 # create post fader output channel matching the input channel
363 channel.post_fader_output_channel = self.mixer.add_output_channel(
364 channel.channel.name + " Out", channel.channel.is_stereo, True
366 channel.post_fader_output_channel.volume = 0
367 channel.post_fader_output_channel.set_solo(channel.channel, True)
369 channel.connect("input-channel-order-changed", self.on_input_channel_order_changed)
371 def add_output_channel(
372 self, name, stereo, volume_cc, balance_cc, mute_cc, display_solo_buttons, color, value
374 try:
375 channel = OutputChannel(self, name, stereo, value)
376 channel.display_solo_buttons = display_solo_buttons
377 channel.color = color
378 self.add_output_channel_precreated(channel)
379 except Exception:
380 error_dialog(self.window, "Output channel creation failed")
381 return
383 channel.assign_midi_ccs(volume_cc, balance_cc, mute_cc)
384 return channel
386 def add_output_channel_precreated(self, channel):
387 frame = Gtk.Frame()
388 frame.add(channel)
389 self.hbox_outputs.pack_end(frame, False, True, 0)
390 self.hbox_outputs.reorder_child(frame, 0)
391 channel.realize()
393 channel_edit_menu_item = Gtk.MenuItem(label=channel.channel_name)
394 self.channel_edit_output_menu.append(channel_edit_menu_item)
395 channel_edit_menu_item.connect("activate", self.on_edit_output_channel, channel)
396 self.channel_edit_output_menu_item.set_sensitive(True)
398 channel_remove_menu_item = Gtk.MenuItem(label=channel.channel_name)
399 self.channel_remove_output_menu.append(channel_remove_menu_item)
400 channel_remove_menu_item.connect("activate", self.on_remove_output_channel, channel)
401 self.channel_remove_output_menu_item.set_sensitive(True)
403 self.output_channels.append(channel)
404 channel.connect("output-channel-order-changed", self.on_output_channel_order_changed)
406 # ---------------------------------------------------------------------------------------------
407 # Signal/event handlers
409 # ---------------------------------------------------------------------------------------------
410 # NSM
412 def nsm_react(self):
413 self.nsm_client.reactToMessage()
414 return True
416 def nsm_hide_cb(self, *args):
417 self.window.hide()
418 self.visible = False
419 self.nsm_client.announceGuiVisibility(False)
421 def nsm_show_cb(self):
422 width, height = self.window.get_size()
423 self.window.show_all()
424 self.paned.set_position(self.paned_position / self.width * width)
426 self.visible = True
427 self.nsm_client.announceGuiVisibility(True)
429 def nsm_open_cb(self, path, session_name, client_name):
430 self.create_mixer(client_name, with_nsm=True)
431 self.current_filename = path + ".xml"
432 if os.path.isfile(self.current_filename):
433 try:
434 with open(self.current_filename, "r") as fp:
435 self.load_from_xml(fp, from_nsm=True)
436 except Exception as exc:
437 # Re-raise with more meaningful error message
438 raise IOError(
439 "Error loading project file '{}': {}".format(self.current_filename, exc)
442 def nsm_save_cb(self, path, session_name, client_name):
443 self.current_filename = path + ".xml"
444 f = open(self.current_filename, "w")
445 self.save_to_xml(f)
446 f.close()
448 def nsm_exit_cb(self, path, session_name, client_name):
449 Gtk.main_quit()
451 # ---------------------------------------------------------------------------------------------
452 # POSIX signals
454 def sighandler(self, signum, frame):
455 log.debug("Signal %d received.", signum)
456 if signum == signal.SIGUSR1:
457 GLib.timeout_add(0, self.on_save_cb)
458 elif signum == signal.SIGINT or signum == signal.SIGTERM:
459 GLib.timeout_add(0, self.on_quit_cb)
460 else:
461 log.warning("Unknown signal %d received.", signum)
463 # ---------------------------------------------------------------------------------------------
464 # GTK signals
466 def on_about(self, *args):
467 about = Gtk.AboutDialog()
468 about.set_name("jack_mixer")
469 about.set_program_name("jack_mixer")
470 about.set_copyright(
471 "Copyright © 2006-2021\n"
472 "Nedko Arnaudov,\n"
473 "Frédéric Péters, Arnout Engelen,\n"
474 "Daniel Sheeler, Christopher Arndt"
476 about.set_license(
477 """\
478 jack_mixer is free software; you can redistribute it and/or modify it
479 under the terms of the GNU General Public License as published by the
480 Free Software Foundation; either version 2 of the License, or (at your
481 option) any later version.
483 jack_mixer is distributed in the hope that it will be useful, but
484 WITHOUT ANY WARRANTY; without even the implied warranty of
485 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
486 General Public License for more details.
488 You should have received a copy of the GNU General Public License along
489 with jack_mixer; if not, write to the Free Software Foundation, Inc., 51
490 Franklin Street, Fifth Floor, Boston, MA 02110-130159 USA"""
492 about.set_authors(
494 "Nedko Arnaudov <nedko@arnaudov.name>",
495 "Christopher Arndt <chris@chrisarndt.de>",
496 "Arnout Engelen <arnouten@bzzt.net>",
497 "John Hedges <john@drystone.co.uk>",
498 "Olivier Humbert <trebmuh@tuxfamily.org>",
499 "Sarah Mischke <sarah@spooky-online.de>",
500 "Frédéric Péters <fpeters@0d.be>",
501 "Daniel Sheeler <dsheeler@pobox.com>",
502 "Athanasios Silis <athanasios.silis@gmail.com>",
505 about.set_logo_icon_name("jack_mixer")
506 about.set_version(__version__)
507 about.set_website("https://rdio.space/jackmixer/")
508 about.run()
509 about.destroy()
511 def on_delete_event(self, widget, event):
512 if self.nsm_client:
513 self.nsm_hide_cb()
514 return True
516 return self.on_quit_cb(on_delete=True)
518 def add_file_filters(self, dialog):
519 filter_xml = Gtk.FileFilter()
520 filter_xml.set_name("XML files")
521 filter_xml.add_mime_type("text/xml")
522 dialog.add_filter(filter_xml)
523 filter_all = Gtk.FileFilter()
524 filter_all.set_name("All files")
525 filter_all.add_pattern("*")
526 dialog.add_filter(filter_all)
528 def _open_project(self, filename):
529 try:
530 with open(filename, "r") as fp:
531 self.load_from_xml(fp)
532 except Exception as exc:
533 error_dialog(self.window, "Error loading project file '%s': %s", filename, exc)
534 else:
535 self.current_filename = filename
536 return True
538 def on_open_cb(self, *args):
539 dlg = Gtk.FileChooserDialog(
540 title="Open project", parent=self.window, action=Gtk.FileChooserAction.OPEN
542 dlg.add_buttons(
543 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK
545 dlg.set_default_response(Gtk.ResponseType.OK)
547 default_project_path = self.gui_factory.get_default_project_path()
549 if self.current_filename:
550 dlg.set_current_folder(os.path.dirname(self.current_filename))
551 else:
552 dlg.set_current_folder(self.last_project_path or default_project_path or os.getcwd())
554 if default_project_path:
555 dlg.add_shortcut_folder(default_project_path)
557 self.add_file_filters(dlg)
559 if dlg.run() == Gtk.ResponseType.OK:
560 filename = dlg.get_filename()
561 if self._open_project(filename):
562 self.recentmanager.add_item("file://" + os.path.abspath(filename))
564 dlg.destroy()
566 def on_recent_file_chosen(self, recentchooser):
567 item = recentchooser.get_current_item()
569 if item and item.exists():
570 log.debug("Recent file menu entry selected: %s", item.get_display_name())
571 uri = item.get_uri()
572 if not self._open_project(urlparse(uri).path):
573 self.recentmanager.remove_item(uri)
575 def _save_project(self, filename):
576 with open(filename, "w") as fp:
577 self.save_to_xml(fp)
579 def on_save_cb(self, *args):
580 if not self.current_filename:
581 return self.on_save_as_cb()
583 try:
584 self._save_project(self.current_filename)
585 except Exception as exc:
586 error_dialog(
587 self.window, "Error saving project file '%s': %s", self.current_filename, exc
590 def on_save_as_cb(self, *args):
591 dlg = Gtk.FileChooserDialog(
592 title="Save project", parent=self.window, action=Gtk.FileChooserAction.SAVE
594 dlg.add_buttons(
595 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK
597 dlg.set_default_response(Gtk.ResponseType.OK)
598 dlg.set_do_overwrite_confirmation(True)
600 default_project_path = self.gui_factory.get_default_project_path()
602 if self.current_filename:
603 dlg.set_filename(self.current_filename)
604 else:
605 dlg.set_current_folder(self.last_project_path or default_project_path or os.getcwd())
606 filename = "{}-{}.xml".format(
607 getpass.getuser(), datetime.datetime.now().strftime("%Y%m%d-%H%M")
609 dlg.set_current_name(filename)
611 if default_project_path:
612 dlg.add_shortcut_folder(default_project_path)
614 self.add_file_filters(dlg)
616 if dlg.run() == Gtk.ResponseType.OK:
617 save_path = dlg.get_filename()
618 save_dir = os.path.dirname(save_path)
619 if os.path.isdir(save_dir):
620 self.last_project_path = save_dir
622 filename = dlg.get_filename()
623 try:
624 self._save_project(filename)
625 except Exception as exc:
626 error_dialog(self.window, "Error saving project file '%s': %s", filename, exc)
627 else:
628 self.current_filename = filename
629 self.recentmanager.add_item("file://" + os.path.abspath(filename))
631 dlg.destroy()
633 def on_quit_cb(self, *args, on_delete=False):
634 if not self.nsm_client and self.gui_factory.get_confirm_quit():
635 dlg = Gtk.MessageDialog(
636 parent=self.window,
637 message_type=Gtk.MessageType.QUESTION,
638 buttons=Gtk.ButtonsType.NONE,
640 dlg.set_markup("<b>Quit application?</b>")
641 dlg.format_secondary_markup(
642 "All jack_mixer ports will be closed and connections lost,"
643 "\nstopping all sound going through jack_mixer.\n\n"
644 "Are you sure?"
646 dlg.add_buttons(
647 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_QUIT, Gtk.ResponseType.OK
649 response = dlg.run()
650 dlg.destroy()
651 if response != Gtk.ResponseType.OK:
652 return on_delete
654 Gtk.main_quit()
656 def on_shrink_channels_cb(self, widget):
657 for channel in self.channels + self.output_channels:
658 channel.narrow()
660 def on_expand_channels_cb(self, widget):
661 for channel in self.channels + self.output_channels:
662 channel.widen()
664 def on_midi_behavior_mode_changed(self, gui_factory, value):
665 self.mixer.midi_behavior_mode = value
667 def on_preferences_cb(self, widget):
668 if not self.preferences_dialog:
669 self.preferences_dialog = PreferencesDialog(self)
670 self.preferences_dialog.show()
671 self.preferences_dialog.present()
673 def on_add_channel(self, inout="input", default_name="Input"):
674 dialog = getattr(self, "_add_{}_dialog".format(inout), None)
675 values = getattr(self, "_add_{}_values".format(inout), {})
677 if dialog is None:
678 cls = NewInputChannelDialog if inout == "input" else NewOutputChannelDialog
679 dialog = cls(app=self)
680 setattr(self, "_add_{}_dialog".format(inout), dialog)
682 names = {
683 ch.channel_name for ch in (self.channels if inout == "input" else self.output_channels)
685 values.setdefault("name", default_name)
686 while True:
687 if values["name"] in names:
688 values["name"] = add_number_suffix(values["name"])
689 else:
690 break
692 dialog.fill_ui(**values)
693 dialog.set_transient_for(self.window)
694 dialog.show()
695 ret = dialog.run()
696 dialog.hide()
698 if ret == Gtk.ResponseType.OK:
699 result = dialog.get_result()
700 setattr(self, "_add_{}_values".format(inout), result)
701 (self.add_channel if inout == "input" else self.add_output_channel)(**result)
702 if self.visible or self.nsm_client is None:
703 self.window.show_all()
705 def on_add_input_channel(self, widget):
706 return self.on_add_channel("input", "Input")
708 def on_add_output_channel(self, widget):
709 return self.on_add_channel("output", "Output")
711 def on_edit_input_channel(self, widget, channel):
712 log.debug('Editing input channel "%s".', channel.channel_name)
713 channel.on_channel_properties()
715 def on_remove_input_channel(self, widget, channel):
716 log.debug('Removing input channel "%s".', channel.channel_name)
718 def remove_channel_edit_input_menuitem_by_label(widget, label):
719 if widget.get_label() == label:
720 self.channel_edit_input_menu.remove(widget)
722 self.channel_remove_input_menu.remove(widget)
723 self.channel_edit_input_menu.foreach(
724 remove_channel_edit_input_menuitem_by_label, channel.channel_name
727 if self.monitored_channel is channel:
728 channel.monitor_button.set_active(False)
730 for i in range(len(self.channels)):
731 if self.channels[i] is channel:
732 channel.unrealize()
733 del self.channels[i]
734 self.hbox_inputs.remove(channel.get_parent())
735 break
737 if not self.channels:
738 self.channel_edit_input_menu_item.set_sensitive(False)
739 self.channel_remove_input_menu_item.set_sensitive(False)
741 def on_edit_output_channel(self, widget, channel):
742 log.debug('Editing output channel "%s".', channel.channel_name)
743 channel.on_channel_properties()
745 def on_remove_output_channel(self, widget, channel):
746 log.debug('Removing output channel "%s".', channel.channel_name)
748 def remove_channel_edit_output_menuitem_by_label(widget, label):
749 if widget.get_label() == label:
750 self.channel_edit_output_menu.remove(widget)
752 self.channel_remove_output_menu.remove(widget)
753 self.channel_edit_output_menu.foreach(
754 remove_channel_edit_output_menuitem_by_label, channel.channel_name
757 if self.monitored_channel is channel:
758 channel.monitor_button.set_active(False)
760 for i in range(len(self.channels)):
761 if self.output_channels[i] is channel:
762 channel.unrealize()
763 del self.output_channels[i]
764 self.hbox_outputs.remove(channel.get_parent())
765 break
767 if not self.output_channels:
768 self.channel_edit_output_menu_item.set_sensitive(False)
769 self.channel_remove_output_menu_item.set_sensitive(False)
771 def on_channel_rename(self, oldname, newname):
772 def rename_channels(container, parameters):
773 if container.get_label() == parameters["oldname"]:
774 container.set_label(parameters["newname"])
776 rename_parameters = {"oldname": oldname, "newname": newname}
777 self.channel_edit_input_menu.foreach(rename_channels, rename_parameters)
778 self.channel_edit_output_menu.foreach(rename_channels, rename_parameters)
779 self.channel_remove_input_menu.foreach(rename_channels, rename_parameters)
780 self.channel_remove_output_menu.foreach(rename_channels, rename_parameters)
781 log.debug('Renaming channel from "%s" to "%s".', oldname, newname)
783 def reorder_menu_item(self, menu, source_label, dest_label):
784 pos = -1
785 source_item = None
786 for i, menuitem in enumerate(menu.get_children()):
787 label = menuitem.get_label()
788 if label == source_label:
789 source_item = menuitem
790 elif label == dest_label:
791 pos = i
793 if pos != -1 and source_item is not None:
794 menu.reorder_child(source_item, pos)
796 def reorder_channels(self, container, source_name, dest_name, reverse=False):
797 frames = container.get_children()
799 for frame in frames:
800 if source_name == frame.get_child().channel_name:
801 source_frame = frame
802 break
804 if reverse:
805 frames.reverse()
807 for pos, frame in enumerate(frames):
808 if dest_name == frame.get_child().channel_name:
809 container.reorder_child(source_frame, pos)
810 break
812 def on_input_channel_order_changed(self, widget, source_name, dest_name):
813 self.channels.clear()
814 self.reorder_channels(self.hbox_inputs, source_name, dest_name)
816 for frame in self.hbox_inputs.get_children():
817 self.channels.append(frame.get_child())
819 for menu in (self.channel_edit_input_menu, self.channel_remove_input_menu):
820 self.reorder_menu_item(menu, source_name, dest_name)
822 def on_output_channel_order_changed(self, widget, source_name, dest_name):
823 self.output_channels.clear()
824 self.reorder_channels(self.hbox_outputs, source_name, dest_name, reverse=True)
826 for frame in self.hbox_outputs.get_children():
827 self.output_channels.append(frame.get_child())
829 for menu in (self.channel_edit_output_menu, self.channel_remove_output_menu):
830 self.reorder_menu_item(menu, source_name, dest_name)
832 def on_channels_clear(self, widget):
833 dlg = Gtk.MessageDialog(
834 parent=self.window,
835 modal=True,
836 message_type=Gtk.MessageType.WARNING,
837 text="Are you sure you want to clear all channels?",
838 buttons=Gtk.ButtonsType.OK_CANCEL,
841 if not widget or dlg.run() == Gtk.ResponseType.OK:
842 for channel in self.output_channels:
843 channel.unrealize()
844 self.hbox_outputs.remove(channel.get_parent())
845 for channel in self.channels:
846 channel.unrealize()
847 self.hbox_inputs.remove(channel.get_parent())
848 self.channels = []
849 self.output_channels = []
850 self.channel_edit_input_menu = Gtk.Menu()
851 self.channel_edit_input_menu_item.set_submenu(self.channel_edit_input_menu)
852 self.channel_edit_input_menu_item.set_sensitive(False)
853 self.channel_remove_input_menu = Gtk.Menu()
854 self.channel_remove_input_menu_item.set_submenu(self.channel_remove_input_menu)
855 self.channel_remove_input_menu_item.set_sensitive(False)
856 self.channel_edit_output_menu = Gtk.Menu()
857 self.channel_edit_output_menu_item.set_submenu(self.channel_edit_output_menu)
858 self.channel_edit_output_menu_item.set_sensitive(False)
859 self.channel_remove_output_menu = Gtk.Menu()
860 self.channel_remove_output_menu_item.set_submenu(self.channel_remove_output_menu)
861 self.channel_remove_output_menu_item.set_sensitive(False)
863 # Force save-as dialog on next save
864 self.current_filename = None
866 dlg.destroy()
868 def read_meters(self):
869 for channel in self.channels:
870 channel.read_meter()
871 for channel in self.output_channels:
872 channel.read_meter()
873 return True
875 def midi_events_check(self):
876 for channel in self.channels + self.output_channels:
877 channel.midi_events_check()
878 return True
880 def get_monitored_channel(self):
881 return self._monitored_channel
883 def set_monitored_channel(self, channel):
884 if channel == self._monitored_channel:
885 return
886 self._monitored_channel = channel
887 if channel is None:
888 self.monitor_channel.out_mute = True
889 elif isinstance(channel, InputChannel):
890 # reset all solo/mute settings
891 for in_channel in self.channels:
892 self.monitor_channel.set_solo(in_channel.channel, False)
893 self.monitor_channel.set_muted(in_channel.channel, False)
894 self.monitor_channel.set_solo(channel.channel, True)
895 self.monitor_channel.prefader = True
896 self.monitor_channel.out_mute = False
897 else:
898 self.monitor_channel.prefader = False
899 self.monitor_channel.out_mute = False
901 if channel:
902 self.update_monitor(channel)
904 monitored_channel = property(get_monitored_channel, set_monitored_channel)
906 def update_monitor(self, channel):
907 if self._monitored_channel is not channel:
908 return
909 self.monitor_channel.volume = channel.channel.volume
910 self.monitor_channel.balance = channel.channel.balance
911 if isinstance(self.monitored_channel, OutputChannel):
912 # sync solo/muted channels
913 for input_channel in self.channels:
914 self.monitor_channel.set_solo(
915 input_channel.channel, channel.channel.is_solo(input_channel.channel)
917 self.monitor_channel.set_muted(
918 input_channel.channel, channel.channel.is_muted(input_channel.channel)
921 def get_input_channel_by_name(self, name):
922 for input_channel in self.channels:
923 if input_channel.channel.name == name:
924 return input_channel
925 return None
927 # ---------------------------------------------------------------------------------------------
928 # Mixer project (de-)serialization and file handling
930 def save_to_xml(self, file):
931 log.debug("Saving to XML...")
932 b = XmlSerialization()
933 s = Serializator()
934 s.serialize(self, b)
935 b.save(file)
937 def load_from_xml(self, file, silence_errors=False, from_nsm=False):
938 log.debug("Loading from XML...")
939 self.unserialized_channels = []
940 b = XmlSerialization()
941 try:
942 b.load(file, self.serialization_name())
943 except: # noqa: E722
944 if silence_errors:
945 return
946 raise
948 self.on_channels_clear(None)
949 s = Serializator()
950 s.unserialize(self, b)
951 for channel in self.unserialized_channels:
952 if isinstance(channel, InputChannel):
953 if self._init_solo_channels and channel.channel_name in self._init_solo_channels:
954 channel.solo = True
955 self.add_channel_precreated(channel)
956 self._init_solo_channels = None
957 for channel in self.unserialized_channels:
958 if isinstance(channel, OutputChannel):
959 self.add_output_channel_precreated(channel)
960 del self.unserialized_channels
961 width, height = self.window.get_size()
962 if self.visible or not from_nsm:
963 self.window.show_all()
965 if self.output_channels:
966 self.output_channels[-1].volume_digits.select_region(0, 0)
967 self.output_channels[-1].slider.grab_focus()
968 elif self.channels:
969 self.channels[-1].volume_digits.select_region(0, 0)
970 self.channels[-1].volume_digits.grab_focus()
972 self.paned.set_position(self.paned_position / self.width * width)
973 self.window.resize(self.width, self.height)
975 def serialize(self, object_backend):
976 width, height = self.window.get_size()
977 object_backend.add_property("geometry", "%sx%s" % (width, height))
978 pos = self.paned.get_position()
979 object_backend.add_property("paned_position", "%s" % pos)
980 solo_channels = []
981 for input_channel in self.channels:
982 if input_channel.channel.solo:
983 solo_channels.append(input_channel)
984 if solo_channels:
985 object_backend.add_property(
986 "solo_channels", "|".join([x.channel.name for x in solo_channels])
988 object_backend.add_property("visible", "%s" % str(self.visible))
990 def unserialize_property(self, name, value):
991 if name == "geometry":
992 width, height = value.split("x")
993 self.width = int(width)
994 self.height = int(height)
995 return True
996 if name == "solo_channels":
997 self._init_solo_channels = value.split("|")
998 return True
999 if name == "visible":
1000 self.visible = value == "True"
1001 return True
1002 if name == "paned_position":
1003 self.paned_position = int(value)
1004 return True
1005 return False
1007 def unserialize_child(self, name):
1008 if name == InputChannel.serialization_name():
1009 channel = InputChannel(self, "", True)
1010 self.unserialized_channels.append(channel)
1011 return channel
1013 if name == OutputChannel.serialization_name():
1014 channel = OutputChannel(self, "", True)
1015 self.unserialized_channels.append(channel)
1016 return channel
1018 if name == gui.Factory.serialization_name():
1019 return self.gui_factory
1021 def serialization_get_childs(self):
1022 """Get child objects that required and support serialization"""
1023 childs = self.channels[:] + self.output_channels[:] + [self.gui_factory]
1024 return childs
1026 def serialization_name(self):
1027 return "jack_mixer"
1029 # ---------------------------------------------------------------------------------------------
1030 # Main program loop
1032 def main(self):
1033 if not self.mixer:
1034 return
1036 if self.visible or self.nsm_client is None:
1037 width, height = self.window.get_size()
1038 self.window.show_all()
1039 if hasattr(self, "paned_position"):
1040 self.paned.set_position(self.paned_position / self.width * width)
1042 signal.signal(signal.SIGUSR1, self.sighandler)
1043 signal.signal(signal.SIGTERM, self.sighandler)
1044 signal.signal(signal.SIGINT, self.sighandler)
1045 signal.signal(signal.SIGHUP, signal.SIG_IGN)
1047 Gtk.main()
1050 def error_dialog(parent, msg, *args):
1051 log.exception(msg, *args)
1052 err = Gtk.MessageDialog(
1053 parent=parent,
1054 modal=True,
1055 destroy_with_parent=True,
1056 message_type=Gtk.MessageType.ERROR,
1057 buttons=Gtk.ButtonsType.OK,
1058 text=msg % args,
1060 err.run()
1061 err.destroy()
1064 def main():
1065 parser = ArgumentParser()
1066 parser.add_argument(
1067 "-c", "--config", metavar="FILE", help="load mixer project configuration from FILE"
1069 parser.add_argument("-d", "--debug", action="store_true", help="enable debug logging messages")
1070 parser.add_argument(
1071 "client_name", metavar="NAME", nargs="?", default="jack_mixer", help="set JACK client name"
1073 args = parser.parse_args()
1075 logging.basicConfig(
1076 level=logging.DEBUG if args.debug else logging.INFO, format="%(levelname)s: %(message)s"
1079 try:
1080 mixer = JackMixer(args.client_name)
1081 except Exception as e:
1082 error_dialog(None, "Mixer creation failed:\n\n%s", e)
1083 sys.exit(1)
1085 if not mixer.nsm_client and args.config:
1086 try:
1087 with open(args.config) as fp:
1088 mixer.load_from_xml(fp)
1089 except Exception as exc:
1090 error_dialog(mixer.window, "Error loading project file '%s': %s", args.config, exc)
1091 else:
1092 mixer.current_filename = args.config
1094 mixer.window.set_default_size(
1095 60 * (1 + len(mixer.channels) + len(mixer.output_channels)), 300
1098 mixer.main()
1100 mixer.cleanup()
1103 if __name__ == "__main__":
1104 main()