7

With PulseAudio it is possible to manage volume on an application basis, but I find it hardly useful to do it manually. What I'd rather have is the following: I'm usually listening to music but sometimes I want to watch a YouTube video - then I have to manually pause or reduce the volume of the music, often I forget to turn it back on when the video is over.

What would I need to do to automatically reduce the volume of a audio stream (the background music) when another application plays sound?

1

Not much of an answer, but I might as well describe the problems/possibilities I found.

I can't see any way to write a shell script for this. There is no (documented) way to change per application volumes using pactl/pacmd. Nor can I see any way to tell when a new client is added without repeatedly polling with something like:

pactl list short clients

Pulse audio can be set to log through syslog, so one possibility is to have a script called via rsyslog (if the distro has it). See my answer here for an indication of how to do this. This of course depends on pulseaudio logging information about new clients.

This is definitely doable, the pavucontrol program is an excellent example of this kind of thing being done. However, it currently looks like there is no CLI to do the same stuff, so it will likely require a more direct interface to the pulseaudio API.

Update

Looking at @derobert's link, the role ducking module would be easy enough to enable, but it requires specifying media.role properties. I can't find anyway to see what these are! It is likely that they are not defined for a lot of streams (many programs still think they are using ALSA). If there is some way list these and perhaps configure them to be assigned (maybe based on the name of the process), this would be the easiest way.

Update 2

media.role can be set via the PULSE_PROP environmental variable. Eg:

PULSE_PROP='media.role=music' play some_music.mp3 &
pactl list clients | grep -C 10 'media.role = "music"'

This could be set for a different applications by editing .desktop files and/or creating wrapper scripts, but this doesn't seem like a very good way.

  • 3
    There is a documented CLI for changing per-client volumes... E.g., pactl set-sink-input-volume 1384 100%, where 1384 is the client number. The secret is that they're called "sink-inputs" for playback clients (and source-outputs for recording clients). – derobert Mar 12 '14 at 15:55
  • 1
    @derobert this does work, but for me not with the client number. If I use the sink-input number from pactl list sink-inputs rather than the client number, then it works. client seems to be a broader group than sink-input since it includes more than just applications playing sound. I can't find any pulse audio documentation where these terms are properly defined though. – Graeme Mar 13 '14 at 19:34
1

The hints in @derobert and @Graeme's comments are helpful, but the output format is inconvenient, so I wrote a parser to transform it to 0 Name 0.0, which is easier to parse. While doing so, I also added a helper to change the volume easily.

It's possible to embed this into a loop and to do whatever you want. This is in the Nim language.

import osproc, strutils, pegs, os, parseopt2, sequtils

type Application = object
  id: int
  name: string
  volume: float

var verbose = false

proc tryExec(cmd): auto =
  if verbose: echo("executing $1" % [cmd])
  let output = execCmdEx(cmd)
  if output.exitCode != 0:
    raise newException(EBase, "Failed to execute `$1` with error code $2\nOutput:\n===\n$3\n===" %
                              [cmd, $output.exitCode, output.output])
  return output.output

iterator getPlayingApps(): auto =
  let paOutput = tryExec("pactl list sink-inputs")

  let sinkStrings = paOutput.split("\l\l")

  for str in sinkStrings:
    var num: int
    var volume: float
    var applicationName: string

    var matches = ["", ""]

    if str.find(peg"'Sink Input #'{\d+}", matches) != -1:
      num = parseInt(matches[0])
    else:
      raise newException(EBase, "Could not find sink input number in \n===\n$1" % [str])

    if str.find(peg"'Volume'@'/'\s*{\d+}'%'", matches) != -1:
      volume = parseFloat(matches[0])
    else:
      raise newException(EBase, "Could not find application volume in \n===\n$1" % [str])

    if str.find(peg"'application.name = ""'{[^""]+}'""'", matches) != -1:
      applicationName = matches[0]
    else:
      raise newException(EBase, "Could not find application name in \n===\n$1" % [str])

    yield Application(id: num, name: applicationName, volume: volume)

proc adjustAppVolume(app: Application, percent: float) =
  let nextVol = min((app.volume + (app.volume * (percent - 100) / 100.0)).int, 100)
  discard tryExec("pactl set-sink-input-volume $1 $2%" % [$app.id, $nextVol])

proc adjustName(name: string, percent: float) =
  for app in getPlayingApps():
    if app.name.contains(peg(name)):
      adjustAppVolume(app, percent)

proc printInfo() =
  for app in getPlayingApps():
    echo("$1\t$2\t$3" % [$app.id, app.name, $app.volume])

var params: seq[string] = @[]
for kind, key, val in getopt():
  case kind
  of cmdArgument:
    params.add(key)
  of cmdLongOption, cmdShortOption:
    case key
    of "help", "h":
      echo "usage: parsepa [-h|--help] [-v|--verbose] [<name expression> percent (-a|--adjust)] [-p|--print]"
      quit(QuitSuccess)
    of "verbose", "v":
      verbose = true
    of "adjust", "a":
      adjustName(params[0], parseFloat(params[1]))
      quit(QuitSuccess)
    of "print", "p":
      printInfo()
      quit(QuitSuccess)
    else:
      echo "Invalid Argument"
      quit(QuitFailure)
  else:
    echo "Invalid Argument"
    quit(QuitFailure)

x64 binary in this gist, for the lazy: https://gist.github.com/flaviut/9feb9a75bd452e6ddd03

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.