Pythonでネットラジオプレイヤーを書いてみた

今日のエントリは、Python + GStreamer + GTK2 + libnotify の組み合わせでネットラジオプレイヤーを作ってみましたので、そのソースコード公開&参考サイトメモです。

完成形

こちらが動作時の画面キャプチャです。

起動するとタスクバーのトレイ部分にアイコンが増え、自動でラジオの再生が始まります。アイコンの左クリックで停止/再生のトグル切り替え、右クリックでメニューが表示され別のラジオ局に切り替えたり出来ます。

ラジオからの曲情報は、曲が切り替わったタイミングでバルーン表示されますし、アイコンにマウスカーソルを合わせるとTipとしても表示されます。

ソースコード

使用したプログラミング言語Python で 120 行ほどのコードになりました。

ライブラリとしては、音楽再生にGStreamer、トレイのステータスアイコンやメニュー表示に GTK2、曲名のバルーン通知に libnotify を、それぞれPythonバインディング経由で使用しています。

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import pygst
pygst.require('0.10')
import gst
import gtk
import os
import pynotify

os.putenv("GST_TAG_ENCODING", "CP932");

class NetRadioPlayer(object):

    def __init__(self, stations):

        self._stations = stations

        self.menu = gtk.Menu()
        def add_image_menu(stock, func):
            item = gtk.ImageMenuItem(stock)
            item.connect("activate", lambda event: func())
            self.menu.append(item)
        add_image_menu(gtk.STOCK_MEDIA_STOP, self.stop)
        for (name, uri) in stations:
            def make_handler(uri):
                return lambda event: (self.change(uri), self.play())
            item = gtk.MenuItem(name)
            item.connect("activate", make_handler(uri))
            self.menu.append(item)
        self.menu.append(gtk.SeparatorMenuItem())
        add_image_menu(gtk.STOCK_QUIT, self.quit)
        self.menu.show_all()

        self.bin = gst.element_factory_make("playbin2")
        self.pipeline = gst.Pipeline()
        self.pipeline.add(self.bin)

        bus = self.pipeline.get_bus()
        bus.add_signal_watch()
        bus.connect("message", self.on_message)

        icon = gtk.StatusIcon()
        icon.connect('activate', self.on_icon_activate)
        icon.connect('popup-menu', self.on_icon_popup_menu)
        self.icon = icon

        if not pynotify.init("NetRadioPlayer"):
            print "failed to init pynotify"
            sys.exit(0)
        self._notify = pynotify.Notification("-", "-")
        self._notify.attach_to_status_icon(icon)

        self.change(stations[0][1])
        self.stop()

    def change(self, uri):
        if self.bin.props.uri != uri:
            self.stop()
            self.bin.props.uri = uri
            self._tags = gst.TagList()

    def play(self):
        self.pipeline.set_state(gst.STATE_PLAYING)
        self.icon.props.stock = gtk.STOCK_MEDIA_PLAY

    def stop(self):
        self.pipeline.set_state(gst.STATE_NULL)
        self.pipeline.get_state() # this will block until state changed
        self.icon.props.stock = gtk.STOCK_MEDIA_STOP

    def quit(self):
        self.stop()
        gtk.main_quit()

    def on_icon_activate(self, event):
        if self.pipeline.get_state()[1] == gst.STATE_PLAYING:
            self.stop()
        else:
            self.play()

    def on_icon_popup_menu(self, status, button, time):
        self.menu.popup(None, None, None, button, time)

    def notify(self):
        try:
            song = self._tags["title"]
            station = self._tags["organization"] + " " + self._tags["genre"]
            self._notify.update(song, station)
            if not self._notify.show():
                pass # FIXME
            tooltip = '<big>%s</big>\n%s' % (song, station)
            self.icon.props.tooltip_markup = tooltip
        except KeyError:
            pass

    def on_message(self, bus, message):
        t = message.type
        if t == gst.MESSAGE_EOS or t == gst.MESSAGE_ERROR:
            self.stop()
        elif t == gst.MESSAGE_TAG:
            tags = message.parse_tag()
            self._tags = self._tags.merge(tags, gst.TAG_MERGE_REPLACE)
            self.notify()

if __name__ == '__main__':
    gtk.gdk.threads_init()

    p = NetRadioPlayer([
        (u"BLACK ANGEL 同人音楽 東方SIDE",
         "http://std1.ladio.net:8070/doujin_tou.mp3"),
        (u"初音ミクたれ流し",
         "http://std2.ladio.net:8120/mikumikutare"),
    ])
    p.play()

    gtk.main()

Pythonに不慣れな人間が書いたコードなので、サンプルとしては不向きかもしれませんが、似たようなことをしようとしている人には参考になるかもしれません。

コードの解説と参考サイト

実は、コードの半分はメニュー対応のためのコードで、右クリックメニューのサポートを追加する前のコードは70行ほどでした。メニューアイテムを1つ1つ個別に作成してますし、項目ごとのハンドラも作らなきゃいけないので、どうしてもコードがかさんでしまうんですよね。

ラジオ局情報とタグの文字コード

ラジオ局情報はスクリプトの中に埋め込んでいます@コードの最後の方。これは私が聞く局が固定されているので問題無いのですが、そうではない人は、ねとらじなら ねとらじの技術情報のページ を参照して、ラジオ局リストを取得するコードを書けば良いんじゃないかと思います。

最初の方にある GST_TAG_ENCODING の指定は、以前書いたタグの文字化け問題の対策ですね。このスクリプトのプロセスの環境変数を直接いじってやることで、ユーザーの環境変数の設定に依存せず、Shift JIS を強制できます。タグの文字コードはラジオ局サーバーの設定次第なので、ラジオ局情報をコードに埋め込んでいるこのスクリプトの場合は、このように強制してしまって問題無いでしょう。

GStreamer

パイプライン構築は playbin2 に完全に任せているので、URIを指定したら STATE_PLAYING に移行させるだけです。

バスから来るメッセージは、エラー停止の時の念のための処理と、タグ情報が届いた時の処理を行っています。タグ情報はオフィシャルチュートリアルのタグの節にも書いてあるとおり、多数のエレメントがバラバラの情報を送出してきます。icecast系のネットラジオの場合オーディオのビットレートなどの情報の他、ラジオ局のタグ情報が再生開始時に1度送られ、再生開始時と曲の変わり目で曲名タグが送られて来ますので、タグ情報はためておいて、曲名情報が届いた時だけバルーン通知を上げるようにしています。

上記ソースコード上では、pipeline と bus と bin(playbin2) あたりのキーワードを拾って読めばだいたい GStreamer 関連のコードです。

gtk.StatusIcon と PyNotify

gtk.StatusIcon と pynotify(libnotify) の使い方は、こちらにかかれているものとほとんど同じです。

gtk.StatusIconとpynotifyをくっつける - conc7996のメモ

ただし、ネットラジオの場合、接続したタイミングがたまたま曲が切り替わる直前だった場合、普通に実装してしまうと2つの通知が連続して上がってしまって見苦しいので、バルーン表示中(or 表示前)だった場合は動的に上書きされるよう、update()を使っています。

ステータスアイコンにカーソルを合わせたときに出るチップに表示される文字列はアイコンの tooltip_markup プロパティで設定していますが、これは pango に渡って処理されますので pango のマークアップが使えます。マークアップの詳細はこちら:Text Attribute Markup: Pango Reference Manual

この手の情報を得るためのコツ

このページをググって見つけた方もいるかと思いますが、新しいライブラリを使えるようになる道はそれだけではありません。まともなライブラリなら公式ドキュメントが充実していることが多いので、そちらを積極的に活用してみてはいかがでしょうか。

まずは公式のチュートリアル(例えば PyGTK なら http://www.pygtk.org/pygtk2tutorial/index.html )を見て雰囲気を掴み、用語やクラス名、メソッド名などがなんとなく分かったら、あとはリファレンス (PyGTK は http://library.gnome.org/devel/pygtk/stable/ 、その前提知識の GObject は http://library.gnome.org/devel/pygobject/stable/ )を確認しながら自分のやりたいことを実現させる、というのが結局は近道なんじゃないかと思います。GStreamer も、公式サイトのドキュメントページ http://gstreamer.freedesktop.org/documentation/ にある "Application Development Manual" がチュートリアル的な文章で、これをざっと読めば概念が一通り理解できるようになります。

ググったりCookbookを漁ったりして見つける「サンプル」、読んで理解する「チュートリアル」、辞書としてつかう「リファレンス」、この3つはどれが欠けても苦労することになるでしょう。

まぁ、デスクトップ通知サービスのD-Busの仕様をそのまま見せている libnotify のようなライブラリには、まともなドキュメントが無いものもあったりするので油断はならないわけですが。