]> git.alsa-project.org Git - alsa-tools.git/blob - hwmixvolume/hwmixvolume
871c2c582e32b9fdb6eb1339f9de1e90a7b07b69
[alsa-tools.git] / hwmixvolume / hwmixvolume
1 #!/usr/bin/env python
2
3 # hwmixvolume - ALSA hardware mixer volume control applet
4 # Copyright (c) 2009-2010 Clemens Ladisch
5 # Copyright (c) 2018 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
6 #
7 # Permission to use, copy, modify, and/or distribute this software for any
8 # purpose with or without fee is hereby granted, provided that the above
9 # copyright notice and this permission notice appear in all copies.
10 #
11 # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
12 # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
13 # AND FITNESS.  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
14 # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
15 # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
16 # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
17 # PERFORMANCE OF THIS SOFTWARE.
18
19 import gi
20 gi.require_version('GLib', '2.0')
21 gi.require_version('Gtk', '3.0')
22 from gi.repository import GLib, Gtk
23 from pyalsa import alsacard, alsahcontrol
24
25 INTF_PCM = alsahcontrol.interface_id['PCM']
26 INTF_MIXER = alsahcontrol.interface_id['MIXER']
27 TYPE_INTEGER = alsahcontrol.element_type['INTEGER']
28 EVENT_VALUE = alsahcontrol.event_mask['VALUE']
29 EVENT_INFO = alsahcontrol.event_mask['INFO']
30 EVENT_REMOVE = alsahcontrol.event_mask_remove
31
32 class Stream:
33     def __init__(self, element, parent):
34         self.element = element
35         self.element.set_callback(self)
36         self.parent = parent
37         self.label = None
38         self.scales = []
39         self.adjustments = []
40         self.callback(self.element, EVENT_INFO)
41
42     def destroy(self):
43         self.deactivate()
44
45     def callback(self, e, mask):
46         if mask == EVENT_REMOVE:
47             self.deactivate()
48         elif (mask & EVENT_INFO) != 0:
49             info = alsahcontrol.Info(self.element)
50             if info.is_inactive:
51                 self.deactivate()
52             else:
53                 self.activate()
54         elif (mask & EVENT_VALUE) != 0:
55             self.update_scales_from_ctl()
56
57     def activate(self):
58         if self.label:
59             return
60         info = alsahcontrol.Info(self.element)
61         value = alsahcontrol.Value(self.element)
62         value.read()
63         values = value.get_tuple(TYPE_INTEGER, info.count)
64         self.label = Gtk.Label.new(self.get_label(info))
65         self.label.set_single_line_mode(True)
66         self.parent.scales_vbox.add(self.label)
67         for i in range(info.count):
68             adj = Gtk.Adjustment(value=values[i],
69                     lower=info.min, upper=info.max,
70                     step_incr=1,
71                     page_incr=(info.max-info.min+1)/8)
72             adj.connect('value-changed', self.update_ctl_from_scale, i)
73             scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj)
74             scale.set_draw_value(False)
75             self.parent.scales_vbox.add(scale)
76             self.scales.append(scale)
77             self.adjustments.append(adj)
78         self.parent.scales_vbox.show_all()
79         self.parent.update_msg_label()
80
81     def deactivate(self):
82         if not self.label:
83             return
84         self.label.destroy()
85         for s in self.scales:
86             s.destroy()
87         self.label = None
88         self.scales = []
89         self.adjustments = []
90         self.parent.update_msg_label()
91
92     def update_scales_from_ctl(self):
93         if not self.label:
94             return
95         count = len(self.adjustments)
96         value = alsahcontrol.Value(self.element)
97         value.read()
98         values = value.get_tuple(TYPE_INTEGER, count)
99         for i in range(count):
100             self.adjustments[i].set_value(values[i])
101
102     def update_ctl_from_scale(self, adj, index):
103         scale_value = adj.get_value()
104         value_to_set = int(round(adj.get_value()))
105         count = len(self.adjustments)
106         value = alsahcontrol.Value(self.element)
107         if self.parent.lock_check.get_active():
108             values = [value_to_set  for i in range(count)]
109         else:
110             value.read()
111             values = value.get_array(TYPE_INTEGER, count)
112             values[index] = value_to_set
113         value.set_array(TYPE_INTEGER, values)
114         value.write()
115         if value_to_set != scale_value:
116             adj.set_value(value_to_set)
117
118     def get_label(self, info):
119         pid = self.get_pid(info)
120         if pid:
121             cmdline = self.get_pid_cmdline(pid)
122             if cmdline:
123                 return cmdline
124             else:
125                 return "PID %d" % pid
126         else:
127             name = info.name
128             if name[-7:] == " Volume":
129                 name = name[:-7]
130             if name[-9:] == " Playback":
131                 name = name[:-9]
132             return name
133
134     def get_pid(self, info):
135         card = self.parent.current_card
136         device = info.device
137         subdevice = info.subdevice
138         if subdevice == 0:
139             subdevice = info.index
140         filename = "/proc/asound/card%d/pcm%dp/sub%d/status" % (card, device, subdevice)
141         try:
142             with open(filename, "r") as f:
143                 for line in f:
144                     if line[:9] == "owner_pid":
145                         return int(line.split(':')[1].strip())
146         except IOError:
147             return None
148         return None
149
150     def get_pid_cmdline(self, pid):
151         try:
152             with open("/proc/%d/cmdline" % pid, "r") as f:
153                 cmdline = f.read()
154         except IOError:
155             return None
156         return cmdline.replace('\x00', ' ').strip()
157
158 class MixerWindow(Gtk.Window):
159     card_numbers = alsacard.card_list()
160     current_card = -1
161     hcontrol = None
162     scales_vbox = None
163     msg_label = None
164     streams = []
165     hctl_sources = []
166
167     def __init__(self):
168         Gtk.Window.__init__(self)
169         self.connect('destroy', lambda w: Gtk.main_quit())
170         self.set_title("Hardware Mixer Volumes")
171
172         vbox = Gtk.Grid()
173         vbox.set_orientation(Gtk.Orientation.VERTICAL)
174         self.add(vbox)
175
176         hbox = Gtk.Grid()
177         vbox.add(hbox)
178
179         label = Gtk.Label.new_with_mnemonic("_Sound Card: ")
180         hbox.add(label)
181
182         combo = Gtk.ComboBoxText()
183         combo.set_hexpand(True)
184         for i in self.card_numbers:
185             str = "%d: %s" % (i, alsacard.card_get_name(i))
186             combo.append_text(str)
187         if len(self.card_numbers) > 0:
188             combo.set_active(0)
189         combo.connect('changed', lambda c: self.change_card(self.card_numbers[combo.get_active()]))
190         hbox.add(combo)
191         label.set_mnemonic_widget(combo)
192
193         self.lock_check = Gtk.CheckButton.new_with_mnemonic(label="_Lock Channels")
194         self.lock_check.set_active(True)
195         vbox.add(self.lock_check)
196
197         scrollwin = Gtk.ScrolledWindow()
198         scrollwin.set_policy(hscrollbar_policy=Gtk.PolicyType.NEVER, vscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
199         scrollwin.set_shadow_type(Gtk.ShadowType.NONE)
200         scrollwin.set_vexpand(True)
201         vbox.add(scrollwin)
202
203         self.scales_vbox = Gtk.Grid()
204         self.scales_vbox.set_orientation(Gtk.Orientation.VERTICAL)
205         scrollwin.add(self.scales_vbox)
206
207         label = Gtk.Label()
208         label.set_single_line_mode(True)
209         line_height = max(label.get_size_request().height, 0)
210         label.destroy()
211         scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
212         scale.set_draw_value(False)
213         line_height += max(scale.get_size_request().height, 0)
214         scale.destroy()
215         # always have space for at least four sliders
216         scrollwin.set_size_request(width=-1, height=line_height*4+4)
217
218         # TODO: select the default card or the first card with stream controls
219         if len(self.card_numbers) > 0:
220             self.change_card(self.card_numbers[0])
221         self.update_msg_label()
222
223         self.show_all()
224
225     def change_card(self, cardnum):
226         for s in self.hctl_sources:
227             GLib.source_remove(s)
228         self.hctl_sources = []
229
230         self.hcontrol = self.open_hcontrol_for_card(cardnum)
231
232         for s in self.streams:
233             s.destroy()
234         self.streams = []
235
236         self.current_card = cardnum
237
238         if not self.hcontrol:
239             self.update_msg_label()
240             return
241
242         for id in self.hcontrol.list():
243             if not self.is_stream_elem(id):
244                 continue
245             elem = alsahcontrol.Element(self.hcontrol, id[0])
246             info = alsahcontrol.Info(elem)
247             if not self.is_stream_info(info):
248                 continue
249             stream = Stream(elem, self)
250             self.streams.append(stream)
251
252         for fd,condition in self.hcontrol.poll_fds:
253             self.hctl_sources.append(GLib.io_add_watch(fd, 0, GLib.IOCondition(condition), self.hctl_io_callback))
254
255         self.update_msg_label()
256
257         self.scales_vbox.show_all()
258
259     def update_msg_label(self):
260         needs_msg = len(self.scales_vbox.get_children()) < 2
261         has_msg = self.msg_label
262         if has_msg and not needs_msg:
263             self.msg_label.destroy()
264             self.msg_label = None
265         elif needs_msg:
266             if len(self.streams) > 0:
267                 msg = "There are no open streams."
268             else:
269                 msg = "This card does not have stream controls."
270             if not has_msg:
271                 self.msg_label = Gtk.Label.new(msg)
272                 self.msg_label.set_vexpand(True)
273                 self.scales_vbox.add(self.msg_label)
274                 self.scales_vbox.show_all()
275             elif self.msg_label.get_text() != msg:
276                 self.msg_label.set_text(msg)
277
278     def open_hcontrol_for_card(self, cardnum):
279         devname = "hw:CARD=" + str(cardnum)
280         try:
281             hc = alsahcontrol.HControl(name=devname,
282                     mode=alsahcontrol.open_mode['NONBLOCK'])
283         except:
284             # TODO: alsa error msg
285             dlg = Gtk.MessageDialog(self,
286                     Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
287                     Gtk.MessageType.ERROR, Gtk.ButtonsType.OK,
288                     "Cannot open sound card control device.")
289             dlg.run()
290             dlg.destroy()
291             return None
292         return hc
293
294     def is_stream_elem(self, id):
295         return ((id[1] == INTF_PCM and
296              id[4] in ("PCM Playback Volume", "EMU10K1 PCM Volume")) or
297             (id[1] == INTF_MIXER and
298              id[4] == "VIA DXS Playback Volume"))
299
300     def is_stream_info(self, info):
301         return info.is_readable and info.is_writable and info.type == TYPE_INTEGER
302
303     def hctl_io_callback(self, source, condition):
304         self.hcontrol.handle_events()
305         return True
306
307 def main():
308     MixerWindow()
309     Gtk.main()
310
311 main()
312