試験運用中なLinux備忘録・旧記事

はてなダイアリーで公開していた2007年5月-2015年3月の記事を保存しています。

システムトレイ上のALSA用ミキサーを作成

あたりの内容を踏まえ、システムトレイに常駐するミキサー(ALSA向け)を作成した。
簡単に操作できるようにするため(と手抜きのため)、チャンネルごとに音量変更はできない。
スライダーが出ているときに外部アプリケーションがミキサー値を変更した場合に値を更新できるようにするため、スライダーのウィンドウが出ているときには指定した間隔ごとにミキサー値の取得とスライダーへの反映を行うことができるようにしている(閉じたらこの処理は必要なくなるのでタイマーを解除している)。
(2009/2/25)タイマーについては「PyGTK上のタイマー処理について」を参照。
そのミキサー値の更新やスライダーの長さ、ミキサー項目といった設定項目は${HOME}/.config/pytraymixer.confというファイルに保存するようにしている。
作成はしたがサンプル的な目的で実際には使っていないこともあり、作り込みの足らない部分も結構ある。
[任意]ファイル名: pytraymixer.py

#! /usr/bin/python
# -*- encoding: utf-8 -*-

import shlex
import errno
import sys
import os
try:
  import pygtk
  pygtk.require("2.0")
except:
  pass
try:
  import gobject
  import gtk
except:
  print >> sys.stderr, "Error: PyGTK is not installed"
  sys.exit(1)
if gtk.pygtk_version < (2,10,0):
  errtitle = "Error"
  errmsg = "PyGTK >= 2.10.0 required"
  if gtk.pygtk_version < (2,4,0):
    print >> sys.stderr, errtitle + ": " + errmsg
  else:
    errdlg = gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK)
    errdlg.set_title(errtitle)
    errdlg.set_markup(errmsg)
    errdlg.run()
  sys.exit(1)
try:
  import alsaaudio
except:
  errtitle = "Error"
  errmsg = "pyalsaaudio required"
  errdlg = gtk.MessageDialog(type=gtk.MESSAGE_ERROR, buttons=gtk.BUTTONS_OK)
  errdlg.set_title(errtitle)
  errdlg.set_markup(errmsg)
  errdlg.run()
  sys.exit(1)


# 設定ファイルに保存される項目
configdic = {"mixername"  : None,
             "sliderlen"  : None,
             "reflesh"    : None,
             "interval"   : None,
            }


def run_errordialog(title, markup):
  """
  エラーダイアログを表示
  """
  msgdlg_args = {"type"    : gtk.MESSAGE_ERROR,
                 "buttons" : gtk.BUTTONS_OK,
                }
  errdlg = gtk.MessageDialog(**msgdlg_args)
  errdlg.set_title(title)
  errdlg.set_markup(markup)
  errdlg.run()
  errdlg.destroy()


class ConfigDialog(gtk.Dialog):
  """
  設定ダイアログ
  """
  def __init__(self, *args, **kwargs):
    global configdic
    gtk.Dialog.__init__(self, *args, **kwargs)
    self.label_mixer = gtk.Label("Mixer:")
    self.combo_mixer = gtk.combo_box_new_text()
    for m in alsaaudio.mixers():
      self.combo_mixer.append_text(m)
    self.combo_mixer.set_active(int(configdic["mixername"]))
    self.label_sliderlen = gtk.Label("Slider length:")
    self.label_interval1 = gtk.Label("every")
    self.label_interval2 = gtk.Label("milliseconds")
    self.chkbtn_reflesh = gtk.CheckButton("Reflesh volume:")
    self.chkbtn_reflesh.set_active(bool(int(configdic["reflesh"])))
    self.spinbtn_sliderlen = gtk.SpinButton(gtk.Adjustment(float(configdic["sliderlen"]), 5, 20, 1, 0, 0))
    self.spinbtn_sliderlen.set_numeric(True)
    self.spinbtn_interval = gtk.SpinButton(gtk.Adjustment(float(configdic["interval"]), 1, 99999, 1, 0, 0))
    self.spinbtn_interval.set_numeric(True)
    self.hbox_mixer = gtk.HBox()
    self.hbox_mixer.pack_start(self.label_mixer, False, False, 0)
    self.hbox_mixer.pack_end(self.combo_mixer, False, False, 0)
    self.hbox_sliderlen = gtk.HBox()
    self.hbox_sliderlen.pack_start(self.label_sliderlen, False, False, 0)
    self.hbox_sliderlen.pack_end(self.spinbtn_sliderlen, False, False, 0)
    self.hbox_reflesh = gtk.HBox()
    self.hbox_reflesh.pack_start(self.chkbtn_reflesh, False, False, 0)
    self.hbox_reflesh.pack_start(self.label_interval1, False, False, 0)
    self.hbox_reflesh.pack_start(self.spinbtn_interval, False, False, 0)
    self.hbox_reflesh.pack_end(self.label_interval2, False, False, 0)
    self.vbox.add(self.hbox_mixer)
    self.vbox.add(self.hbox_sliderlen)
    self.vbox.add(self.hbox_reflesh)
    self.combo_mixer.connect("changed", self.combo_changed)
    self.spinbtn_sliderlen.connect("value-changed", self.spinbtn_changed, "sliderlen")
    self.spinbtn_interval.connect("value-changed", self.spinbtn_changed, "interval")
    self.chkbtn_reflesh.connect("toggled", self.chkbtn_toggled)
    self.connect("response", self.response)
    self.show_all()
  def response(self, widget, response_id):
    """
    ダイアログを隠す
    """
    self.hide()
  def combo_changed(self, widget):
    """
    オーディオ出力先のコンボボックス項目の変更を反映
    """
    global configdic
    configdic["mixername"] = widget.get_active()
  def spinbtn_changed(self, widget, name):
    """
    スピンボタン値の変更を反映
    """
    global configdic
    configdic[name] = widget.get_value_as_int()
  def chkbtn_toggled(self, widget):
    """
    音量表示更新のチェックボタンの変更を反映
    """
    global configdic
    configdic["reflesh"] = int(widget.get_active())  # True:1 False:0

class TrayMenu(gtk.Menu):
  """
  システムトレイのメニュー
  """
  def __init__(self, tray):
    gtk.Menu.__init__(self)  # 必須

    self.tray = tray
    self.show = False  # ウィンドウの表示フラグ
    self.channel = alsaaudio.MIXER_CHANNEL_ALL  # 音量変更するチャンネル
    self.timeout_id = None

    # 設定ダイアログ
    self.item_config = gtk.ImageMenuItem(stock_id=gtk.STOCK_PREFERENCES)
    self.item_config.connect("activate", self.config_activate)
    self.append(self.item_config)
    # 終了
    self.item_quit = gtk.ImageMenuItem(stock_id=gtk.STOCK_QUIT)
    self.item_quit.connect("activate", self.quit_activate)
    self.append(self.item_quit)

    # 表示可能にする
    self.show_all()
  def activate(self, widget):
    """
    クリックされたときの処理
    """
    global configdic
    # ウィンドウが表示されている場合は壊す
    if self.show == True:
      self.win.destroy()
      # ミキサー値更新のタイマーが設定されている場合は解除
      if self.timeout_id:
        gobject.source_remove(self.timeout_id)
      self.timeout_id = None
      self.show = False
    # 表示されていない場合にウィンドウ作成
    else:
      # (画面の情報, 表示領域の情報, 方向の情報) を取得
      (screen, area, orientation) = self.tray.get_geometry()
      # ポップアップウィンドウの作成
      self.win = gtk.Window(gtk.WINDOW_POPUP)
      # システムトレイアイコンが縦方向に並ぶ場合(パネルが左右端にある場合など)
      if orientation == gtk.ORIENTATION_VERTICAL:
        scale = gtk.HScale()
        # 画面右端にある場合は左に表示する
        # アイコンのX座標 + 幅 + (幅 x int(configdic["sliderlen"]))
        # つまり「アイコンのX座標 + 幅 x (1 + int(configdic["sliderlen"]))」を画面幅と比較
        if area.x + area.width * (1 + int(configdic["sliderlen"])) > screen.get_width():
          x      = area.x - area.height * int(configdic["sliderlen"])
          y      = area.y
          width  = area.width * int(configdic["sliderlen"])
          height = area.height
          scale.set_value_pos(gtk.POS_RIGHT)
        # それ以外は右に表示する
        else:
          x      = area.x + area.height
          y      = area.y
          width  = area.width * int(configdic["sliderlen"])
          height = area.height
          scale.set_value_pos(gtk.POS_LEFT)
      # システムトレイアイコンが横方向に並ぶ場合(パネルが上下端にある場合など)
      else:
        scale = gtk.VScale()
        scale.set_inverted(True)  # 上が大きい値になるようにする
        # 画面上端にある場合は下に表示する
        if area.y < area.height * int(configdic["sliderlen"]):
          x      = area.x
          y      = area.y + area.height
          width  = area.width
          height = area.height * int(configdic["sliderlen"])
          scale.set_value_pos(gtk.POS_TOP)
        # それ以外は上に表示する
        else:
          x      = area.x
          y      = area.y - area.height * int(configdic["sliderlen"])
          width  = area.width
          height = area.height * int(configdic["sliderlen"])
          scale.set_value_pos(gtk.POS_BOTTOM)
      # 現在のミキサー値を取得
      try:
        self.mixer = alsaaudio.Mixer(alsaaudio.mixers()[int(configdic["mixername"])])
      except alsaaudio.ALSAAudioError:
        return
      # 現在の音量を取得
      # 複数のチャンネルがある場合には平均する手もあるが
      # 今回は0番目の値をそのまま使用
      volume = self.mixer.getvolume()[0]

      # スライダーの設定
      # gtk.Range
      scale.set_update_policy(gtk.UPDATE_CONTINUOUS)  # ドラッグ中も値を反映
      scale.set_adjustment(gtk.Adjustment(value=volume, lower=0, upper=100, step_incr=1, page_incr=5, page_size=0))
      # gtk.Scale
      scale.set_digits(0)  # 小数点以下なし
      # シグナル(値変更時のハンドラと関連付け)
      scale.connect("value_changed", self.value_changed)
      # 上で決めたサイズと位置に調整
      self.win.resize(width, height)
      self.win.move(x, y)
      # スライダーを追加
      self.win.add(scale)
      # ウィンドウを表示
      self.win.show_all()
      # ウィンドウ表示中のミキサー値の更新
      if bool(int(configdic["reflesh"])) == True:
        self.timeout_id = gobject.timeout_add(int(configdic["interval"]), self.timeout, scale)
      self.show = True
  def config_activate(self, widget):
    """
    設定ダイアログを表示
    """
    self.confdlg = ConfigDialog(title="config", buttons=(gtk.STOCK_CLOSE, gtk.RESPONSE_CLOSE))
  def value_changed(self, widget):
    """
    スライダー値が変更されたときにミキサー値を変更
    """
    self.mixer.setvolume(int(widget.get_value()), self.channel)
  def timeout(self, scale):
    """
    音量が外部で変更されていないか、定期的にミキサー値を取得して更新
    """
    global configdic
    try:
      self.mixer = alsaaudio.Mixer(alsaaudio.mixers()[int(configdic["mixername"])])
    except alsaaudio.ALSAAudioError:
      return False
    volume = self.mixer.getvolume()[0]
    scale.set_value(volume)
    return True  # 繰り返す
  def show_menu(self, widget, button, time):
    """
    メニューをポップアップ
    """
    self.popup(None, None, gtk.status_icon_position_menu, button, time, self.tray)
  def quit_activate(self, widget):
    """
    設定を保存して終了
    """
    global rcfile, configdic

    # ディレクトリ~/.configが無ければ作成
    rcdir = os.path.dirname(rcfile)
    try:
      os.mkdir(rcdir)
    except OSError, (errcode, strerr):
      # 既に存在する以外の理由で~/.configが作成できなければダイアログで表示
      if errcode != errno.EEXIST:
        run_errordialog("Failed to make directory for settings", ('Cannot create directory "%(rcdir)s"' % {"rcdir" : rcdir}), strerr)
        gtk.main_quit()
        return
    # 設定ファイルに書き込む
    try:
      f_out = open(rcfile, "w")
      for (key, val) in configdic.iteritems():
        f_out.write("%s\t%s\n" % (key, val))
      f_out.close()
    except IOError, (errcode, strerr):
      run_errordialog("Failed to save config file", ('Cannot save config file "%(rcfile)s"' % {"rcfile" : rcfile}), strerr)
    self.confdlg.destroy()
    gtk.main_quit()

class PyTrayMixer:
  """
  システムトレイアイコンのミキサー
  """
  def main(self):
    global rcfile, configdic
    rcfile = os.path.expanduser("~/.config/pytraymixer.conf")
    tray = gtk.StatusIcon()
    tray.set_from_stock(gtk.STOCK_DIALOG_INFO)  # 仮のアイコン
    menu = TrayMenu(tray)
    tray.connect("popup-menu", menu.show_menu)
    tray.connect("activate", menu.activate)

    # 設定ファイルの読み込み
    try:
      f_in = open(rcfile, "r")
      lex = shlex.shlex(f_in)
      lex.whitespace_split = True
      while True:
        key = lex.get_token()
        val = lex.get_token()
        if not key or not val:
          break
        configdic[key] = val
      f_in.close()
    except IOError:
      # 各種設定の既定値を設定
      configdic["mixername"] = 0
      configdic["sliderlen"] = 8
      configdic["reflesh"] = 0
      configdic["interval"] = 500
    gtk.main()


if __name__ == "__main__":
  app = PyTrayMixer()
  app.main()

使用したバージョン:

  • Python 2.5.2
  • PyGTK 2.13.0
  • pyalsaaudio 0.3