-
-
Notifications
You must be signed in to change notification settings - Fork 261
/
volume_status.py
449 lines (385 loc) · 14.2 KB
/
volume_status.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
"""
Volume control.
Configuration parameters:
blocks: a string, where each character represents a volume level
(default "_▁▂▃▄▅▆▇█")
button_down: button to decrease volume (default 5)
button_mute: button to toggle mute (default 1)
button_up: button to increase volume (default 4)
cache_timeout: how often we refresh this module in seconds.
(default 10)
card: Card to use. amixer supports this. (default None)
channel: channel to track. Default value is backend dependent.
(default None)
command: Choose between "amixer", "pamixer" or "pactl".
If None, try to guess based on available commands.
(default None)
device: Device to use. Defaults value is backend dependent.
"aplay -L", "pactl list sinks short", "pamixer --list-sinks"
(default None)
format: Format of the output.
(default '[\\?if=is_input 😮|♪]: {percentage}%')
format_muted: Format of the output when the volume is muted.
(default '[\\?if=is_input 😶|♪]: muted')
is_input: Is this an input device or an output device?
(default False)
max_volume: Allow the volume to be increased past 100% if available.
pactl and pamixer supports this. (default 120)
thresholds: Threshold for percent volume.
(default [(0, 'bad'), (20, 'degraded'), (50, 'good')])
volume_delta: Percentage amount that the volume is increased or
decreased by when volume buttons pressed.
(default 5)
Format placeholders:
{icon} Character representing the volume level,
as defined by the 'blocks'
{percentage} Percentage volume
Color options:
color_muted: Volume is muted, if not supplied color_bad is used
if set to `None` then the threshold color will be used.
Requires:
alsa-utils: an alternative implementation of linux sound support
pamixer: pulseaudio command-line mixer like amixer
Notes:
If you are changing volume state by external scripts etc and
want to refresh the module quicker than the i3status interval,
send a USR1 signal to py3status in the keybinding.
Example: killall -s USR1 py3status
Examples:
```
# Set thresholds to rainbow colors
volume_status {
thresholds = [
(0, "#FF0000"),
(10, "#E2571E"),
(20, "#FF7F00"),
(30, "#FFFF00"),
(40, "#00FF00"),
(50, "#96BF33"),
(60, "#0000FF"),
(70, "#4B0082"),
(80, "#8B00FF"),
(90, "#FFFFFF")
]
}
```
@author <Jan T> <jans.tuomi@gmail.com>
@license BSD
SAMPLE OUTPUT
{'color': '#00FF00', 'full_text': u'\u266a: 95%'}
mute
{'color': '#FF0000', 'full_text': u'\u266a: muted'}
"""
import math
import re
from time import sleep
from py3status.exceptions import CommandError
STRING_ERROR = "invalid command `{}`"
STRING_NOT_AVAILABLE = "no available binary"
COMMAND_NOT_INSTALLED = "command `{}` not installed"
class Audio:
def __init__(self, parent):
self.card = parent.card
self.channel = parent.channel
self.device = parent.device
self.is_input = parent.is_input
self.max_volume = parent.max_volume
self.parent = parent
self.setup(parent)
def setup(self, parent):
raise NotImplementedError
def run_cmd(self, cmd):
return self.parent.py3.command_run(cmd)
def command_output(self, cmd):
return self.parent.py3.command_output(cmd)
class Amixer(Audio):
def setup(self, parent):
if self.card is None:
self.card = "0"
if self.device is None:
self.device = "default"
if self.channel is None:
controls = self.parent.py3.command_output(
["amixer", "-c", self.card, "-D", self.device, "scontrols"]
).splitlines()
self.channel = controls[-abs(int(self.is_input))].split("'")[1::2][0]
self.cmd = [
"amixer",
"-M",
"-q",
"-c",
self.card,
"-D",
self.device,
"sset",
self.channel,
]
self.get_volume_cmd = [
"amixer",
"-M",
"-c",
self.card,
"-D",
self.device,
"sget",
self.channel,
]
def get_volume(self):
output = self.command_output(self.get_volume_cmd)
# find percentage and status
p = re.compile(r"\[(\d{1,3})%\].*\[(\w{2,3})\]")
perc, muted = p.search(output).groups()
# muted should be 'on' or 'off'
if muted in ["on", "off"]:
muted = muted == "off"
else:
muted = False
return perc, muted
def volume_up(self, delta):
self.run_cmd(self.cmd + [f"{delta}%+"])
def volume_down(self, delta):
self.run_cmd(self.cmd + [f"{delta}%-"])
def toggle_mute(self):
self.run_cmd(self.cmd + ["toggle"])
class Pamixer(Audio):
def setup(self, parent):
if self.device is not None:
dev_target = ["--source" if self.is_input else "--sink", self.device]
elif self.is_input:
dev_target = ["--default-source"]
else:
dev_target = []
self.cmd = ["pamixer", "--allow-boost"] + dev_target
def get_volume(self):
try:
line = self.command_output(self.cmd + ["--get-mute", "--get-volume"])
except CommandError as ce:
# pamixer throws error on zero percent. see #1135
line = ce.output
try:
muted, perc = line.split()
muted = muted == "true"
except ValueError:
muted, perc = None, None
return perc, muted
def volume_up(self, delta):
perc, muted = self.get_volume()
if int(perc) + delta >= self.max_volume:
options = ["--set-volume", str(self.max_volume)]
else:
options = ["--increase", str(delta)]
self.run_cmd(self.cmd + options)
def volume_down(self, delta):
self.run_cmd(self.cmd + ["--decrease", str(delta)])
def toggle_mute(self):
self.run_cmd(self.cmd + ["--toggle-mute"])
class Pactl(Audio):
def setup(self, parent):
# get available device number if not specified
self.detected_devices = {}
self.device_type = "source" if self.is_input else "sink"
self.device_type_pl = self.device_type + "s"
self.device_type_cap = self.device_type[0].upper() + self.device_type[1:]
self.use_default_device = self.device is None
if self.use_default_device:
self.device = self.get_default_device()
else:
# if a device name was present but is used to match multiple
# possible devices sharing the same name pattern we allow ourselves
# to override the device name
self.set_selected_device()
self.update_device()
def update_device(self):
self.re_volume = re.compile(
r"{} (?:#{}|.*?Name: {}).*?Mute: (\w{{2,3}}).*?Volume:.*?(\d{{1,3}})%".format(
self.device_type_cap, self.device, self.device
),
re.M | re.DOTALL,
)
def get_default_device(self):
device_id = None
# Find the default device for the device type
default_dev_pattern = re.compile(rf"^Default {self.device_type_cap}: (.*)$")
output = self.command_output(["pactl", "info"])
for info_line in output.splitlines():
default_dev_match = default_dev_pattern.match(info_line)
if default_dev_match is not None:
device_id = default_dev_match.groups()[0]
break
# with the long gross id, find the associated number
if device_id is not None:
for d_number, d_id in self.get_current_devices().items():
if d_id == device_id:
return d_number
raise RuntimeError(
"Failed to find default {} device. Looked for {}".format(
"input" if self.is_input else "output", device_id
)
)
def set_selected_device(self):
current_devices = self.get_current_devices()
if self.device in current_devices.values():
return
for device_name in current_devices.values():
if self.device in device_name:
self.parent.py3.log(f"device {self.device} detected as {device_name}")
self.device = device_name
break
def get_current_devices(self):
current_devices = {}
output = self.command_output(["pactl", "list", "short", self.device_type_pl])
for line in output.splitlines():
parts = line.split()
if len(parts) < 2:
continue
current_devices[parts[0]] = parts[1]
if current_devices != self.detected_devices:
self.detected_devices = current_devices
self.parent.py3.log(f"available {self.device_type_pl}: {current_devices}")
return current_devices
def get_volume(self):
output = self.command_output(["pactl", "list", self.device_type_pl]).strip()
if self.use_default_device:
self.device = self.get_default_device()
self.update_device()
try:
muted, perc = self.re_volume.search(output).groups()
muted = muted == "yes"
except AttributeError:
muted, perc = None, None
return perc, muted
def volume_up(self, delta):
perc, muted = self.get_volume()
if int(perc) + delta >= self.max_volume:
change = f"{self.max_volume}%"
else:
change = f"+{delta}%"
self.run_cmd(["pactl", "--", f"set-{self.device_type}-volume", self.device, change])
def volume_down(self, delta):
self.run_cmd(
[
"pactl",
"--",
f"set-{self.device_type}-volume",
self.device,
f"-{delta}%",
]
)
def toggle_mute(self):
self.run_cmd(["pactl", f"set-{self.device_type}-mute", self.device, "toggle"])
class Py3status:
""""""
# available configuration parameters
blocks = "_▁▂▃▄▅▆▇█"
button_down = 5
button_mute = 1
button_up = 4
cache_timeout = 10
card = None
channel = None
command = None
device = None
format = r"[\?if=is_input 😮|♪]: {percentage}%"
format_muted = r"[\?if=is_input 😶|♪]: muted"
is_input = False
max_volume = 120
thresholds = [(0, "bad"), (20, "degraded"), (50, "good")]
volume_delta = 5
class Meta:
def deprecate_function(config):
# support old thresholds
return {
"thresholds": [
(0, "bad"),
(config.get("threshold_bad", 20), "degraded"),
(config.get("threshold_degraded", 50), "good"),
]
}
deprecated = {
"function": [{"function": deprecate_function}],
"remove": [
{
"param": "threshold_bad",
"msg": "obsolete set using thresholds parameter",
},
{
"param": "threshold_degraded",
"msg": "obsolete set using thresholds parameter",
},
{
"param": "start_delay",
"msg": "obsolete parameter",
},
],
}
def post_config_hook(self):
if not self.command:
commands = ["pamixer", "pactl", "amixer"]
# pamixer, pactl requires pulseaudio to work
if not self.py3.check_commands(["pulseaudio", "pipewire"]):
commands = ["amixer"]
self.command = self.py3.check_commands(commands)
elif self.command not in ["amixer", "pamixer", "pactl"]:
raise Exception(STRING_ERROR.format(self.command))
elif not self.py3.check_commands(self.command):
raise Exception(COMMAND_NOT_INSTALLED.format(self.command))
if not self.command:
raise Exception(STRING_NOT_AVAILABLE)
# turn integers to strings
if self.card is not None:
self.card = str(self.card)
if self.device is not None:
self.device = str(self.device)
self._init_backend()
self.color_muted = self.py3.COLOR_MUTED or self.py3.COLOR_BAD
def _init_backend(self):
# attempt it a few times since the audio service may still be loading during startup
for i in range(6):
try:
self.backend = globals()[self.command.capitalize()](self)
return
except Exception: # noqa e722
# try again later (exponential backoff)
sleep(0.5 * 2**i)
self.backend = globals()[self.command.capitalize()](self)
def volume_status(self):
perc, muted = self.backend.get_volume()
color = None
icon = None
new_format = self.format
if perc is None:
perc = "?"
elif muted:
color = self.color_muted
new_format = self.format_muted
else:
color = self.py3.threshold_get_color(perc)
icon = self.blocks[
min(
len(self.blocks) - 1,
math.ceil(int(perc) / 100 * (len(self.blocks) - 1)),
)
]
volume_data = {"icon": icon, "percentage": perc}
return {
"cached_until": self.py3.time_in(self.cache_timeout),
"full_text": self.py3.safe_format(new_format, volume_data),
"color": color,
}
def on_click(self, event):
button = event["button"]
if button == self.button_up:
try:
self.backend.volume_up(self.volume_delta)
except TypeError:
pass
elif button == self.button_down:
self.backend.volume_down(self.volume_delta)
elif button == self.button_mute:
self.backend.toggle_mute()
if __name__ == "__main__":
"""
Run module in test mode.
"""
from py3status.module_test import module_test
module_test(Py3status)