ひとり開発 Advent Calendar 2019、20日目の記事です。
2018年11月から、GStreamer/GTK/Valaを使ったサウンドレコーダーアプリを個人で開発しているのですが、それに関する話を書きます。
こんなアプリを開発しています
Linux上で動作するサウンドレコーダーアプリです。主に、Ubuntu LTSベースのelementary OSでの利用を想定していますが、後述するようにほかのディストリビューションでも利用可能です。
対応機能
- カウントダウン機能(最大15秒)
- 録音時間の指定(最大600秒)
- 録音ソース(マイク・システムサウンド・両方)の指定
- 対応するファイル形式は、AAC・FLAC・MP3・Ogg Vorbis・Opus・WAV
- 自動保存機能(録音終了時およびアプリ終了時)
- 状態保存機能(最後にアプリを終了した時の録音の設定やウィンドウの位置を、次回起動時に復元)
インストール
elementary OSを使っていれば、以下のボタンをクリックすると、このアプリをインストールできます。
【2022年10月29日追記】elementary OS以外であれば、Flathubからのインストールがおすすめです。
有志の方が、ほかのディストリビューション向けにパッケージを提供してくださっているみたいです。ただ、私はこれらの管理には関与していないので、多少古いバージョンである可能性があります。
開発のきっかけ
ある時、大学での話し合いで簡単な議事録(といっても、メモ程度ですが)を取ることになりました。その時、elementary OSをインストールしたノートPCを使っていたのですが、話し合いが速く進みすぎて、あまり議事録を取れませんでした。そこで、サウンドレコーダーアプリを使って話し合いを録音して、話し合いの場で聞きそびれたことをあとから聞き直すことにしました。
最初は、GNOMEサウンドレコーダーを使おうと思ったのですが、あまりUIが気に入りませんでした。加えて、録音したファイルにフォーカスが合うと、情報ボタンや削除ボタンが見づらくなるという問題がありました1(下図)。
ということで、車輪の再発明になることを承知の上で、自分でサウンドレコーダーアプリを作ってみることにしました。
開発方針
全体的に、GNOMEサウンドレコーダーを意識して開発しました。例えば、GNOMEサウンドレコーダーで録音可能なファイル形式は、このアプリでも録音可能です。
一方、GNOMEサウンドレコーダーを含めたほとんどのサウンドレコーダーアプリが、音声の録音・再生どちらも対応しているのに対し、あえて「音声を録音する」という機能のみに特化させ、再生は再生アプリに任せるという方針にしました。
一般的なサウンドレコーダーアプリは、ウィンドウ内に録音した音声の一覧を表示させるため、録音形式や録音先の設定は別の設定ウィンドウで行うことが多いようです。変更することが多い設定を深い階層に閉じ込めるのは使いづらいと感じたので、あえてこのような方針を選びました。
あとは、elementary OS向けに開発するということで、elementary OSのHuman Interface Guidlinesと、elementary OSのコーディングスタイルになるべく準拠するように心がけました。
何を使って作るか
バックエンド:GStreamer
GStreamerは、オープンソースのマルチメディアライブラリです。これを使えば、サウンドレコーダーアプリ、動画再生アプリなどマルチメディア関係のアプリを比較的簡単に開発できます。
フロントエンド:GTK
当初、画像エディターのGIMPのために作られた、有名なツールキットです。elementary OSのデスクトップ環境であるPantheonはもちろん、GNOMEやMATEなど、さまざまなデスクトップ環境で使われています。
プログラミング言語:Vala
この3つの用語の中で、おそらく一番知っている人が少ないと思われる単語です。GNOMEが開発するプログラミング言語の1つで、C#に似た構文を持ち、プロパティ、無名関数、foreachなどが利用可能。C言語のソースコードにコンパイルされます。
GNOMEプロジェクトの一部であることから、GearyやShotwellなど一部のGNOMEアプリの開発にも用いられています。elementary OSのコンポーネントのほとんど(アプリ、パネル、ログイン画面など)は、Valaで書かれています。
GStreamer/Valaを使ったコーディング
最後に、GStreamerとValaを使ったコーディングの一例として、私が開発するサウンドレコーダーアプリの録音を開始するメソッドを紹介します。
GStreamerを使用したコーディングの流れは以下のとおりです。
-
パイプラインとエレメントを作成する(
Gst.ElementFactory.make ("エレメントの種類", "任意の名前")
) - エレメントをパイプラインに追加する(
パイプラインの変数名.add (エレメント名)
、複数ならパイプラインの変数名.add_many (エレメント名, エレメント名, …)
) - ほかのエレメントとパッドやシンクと呼ばれる属性を利用してリンクさせる
では、解説していきます。なお、以下のコードでは説明向けに多少コードを改変してあります。
下準備
// 実際には以下の1行はこのメソッド外にあります
private enum SourceDevice {
MIC,
SYSTEM,
BOTH
}
……
var settings = GLib.Settings ("com.github.username.appname");
SourceDevice device_id = (SourceDevice) settings.get_enum ("device");
まず、GSettings(Windowsでいうレジストリ)の項目から、録音対象のデバイスを確認します。この項目には、録音画面でユーザーが選択した「録音ソース」の設定が保存されています。選択項目の変更が、device_id
の値と同期されるようにしているからです。device_id
が0ならマイクのみ、1ならシステムサウンドのみ、2ならその両方の音声を取得します。intだと、数字とそれが表す意味の関連性がないので、enumに変換してみました。なお、GSettingを介しているのは、次回起動時に前回選択した項目を復元するためです。
パイプラインとエレメントを作成
// インスタンスメンバーなので、実際には以下の1行はこのメソッド外にあります
private Gst.Element sys_sound;
……
var pipeline = new Gst.Pipeline ("pipeline");
var mic_sound = Gst.ElementFactory.make ("pulsesrc", "mic_sound");
var sink = Gst.ElementFactory.make ("filesink", "sink");
if (device_id != SourceDevice.MIC) {
sys_sound = Gst.ElementFactory.make ("pulsesrc", "sys_sound");
}
GStreamerのエレメントを作成します。パイプライン(pipeline
)が、GStreamerの各エレメントを格納する場所です。pulsesrc
は音声をキャプチャーするエレメントです。mic_sound
をマイクのキャプチャー用、sys_sound
をシステムサウンドのキャプチャー用として宣言します。filesink
はファイルを保存するエレメントです。
録音デバイスを取得
if (device_id != SourceDevice.MIC) {
string default_output = "";
try {
string sound_devices = "";
GLib.Process.spawn_command_line_sync ("pacmd list-sinks", out sound_devices);
var regex = new GLib.Regex ("(?<=\\*\\sindex:\\s\\d\\s\\sname:\\s<)[\\w\\.\\-]*");
GLib.MatchInfo match_info;
if (regex.match (sound_devices, 0, out match_info)) {
default_output = match_info.fetch (0);
}
default_output += ".monitor";
sys_sound.set ("device", default_output);
} catch (Error e) {
warning (e.message);
}
}
if (device_id != SourceDevice.SYSTEM) {
string default_input = "";
try {
string sound_devices = "";
GLib.Process.spawn_command_line_sync ("pacmd list-sources", out sound_devices);
var regex = new GLib.Regex ("(?<=\\*\\sindex:\\s\\d\\s\\sname:\\s<)[\\w\\.\\-]*");
GLib.MatchInfo match_info;
if (regex.match (sound_devices, 0, out match_info)) {
default_input = match_info.fetch (0);
}
mic_sound.set ("device", default_input);
} catch (Error e) {
warning (e.message);
}
}
メソッドの一番最初で取得したdevice_id
の値に従って、録音デバイスをそれぞれのpulsesrc
のdeviceという属性に結びつけます。ちなみに、録音デバイスを取得するところは、elementary OS向けのスクリーンキャストアプリのソースコードを参考にしました。
エンコーダーとmuxerを作成
Gst.Element encoder;
Gst.Element muxer = null;
suffix = "";
string file_format = settings.get_string ("format");
switch (file_format) {
case "aac":
encoder = Gst.ElementFactory.make ("avenc_aac", "encoder");
muxer = Gst.ElementFactory.make ("mp4mux", "muxer");
suffix = ".m4a";
break;
case "flac":
encoder = Gst.ElementFactory.make ("flacenc", "encoder");
suffix = ".flac";
break;
case "mp3":
encoder = Gst.ElementFactory.make ("lamemp3enc", "encoder");
suffix = ".mp3";
break;
case "ogg":
encoder = Gst.ElementFactory.make ("vorbisenc", "encoder");
muxer = Gst.ElementFactory.make ("oggmux", "muxer");
suffix = ".ogg";
break;
case "opus":
encoder = Gst.ElementFactory.make ("opusenc", "encoder");
muxer = Gst.ElementFactory.make ("oggmux", "muxer");
suffix = ".opus";
break;
case "wav":
encoder = Gst.ElementFactory.make ("wavenc", "encoder");
suffix = ".wav";
break;
default:
assert_not_reached ();
}
続いて、エンコーダーを作成します。ファイル形式によってエンコーダーが異なるので、switch文で条件分岐しています。ファイル形式によっては、muxer
が必要なこともありますので、そちらも作成しておきます。なおmuxer
をnullで初期化しているのは、あとでmuxer
の有無で処理を分ける必要があるためです。
保存先を設定
string tmp_filename = "reco_" + new GLib.DateTime.now_local ().to_unix ().to_string ();
string tmp_full_path = GLib.Environment.get_tmp_dir () + "/%s%s".printf (tmp_filename, suffix);
sink.set ("location", tmp_full_path);
保存先を設定します。一時的に/Tmp
以下に録音中のファイルを保存し、録音が完了したらユーザーの設定した保存先にファイルを移動するという方針で開発しているため、ここでは一時的な保存先を指定するにとどまっています。
エレメントをパイプラインに追加し、リンクさせる(&ミキサーを設定)
pipeline.add_many (encoder, sink);
switch (device_id) {
case SourceDevice.MIC:
pipeline.add (mic_sound);
mic_sound.link (encoder);
break;
case SourceDevice.SYSTEM:
pipeline.add (sys_sound);
sys_sound.link (encoder);
break;
case SourceDevice.BOTH:
var mixer = Gst.ElementFactory.make ("audiomixer", "mixer");
pipeline.add_many (mic_sound, sys_sound, mixer);
mic_sound.get_static_pad ("src").link (mixer.get_request_pad ("sink_%u"));
sys_sound.get_static_pad ("src").link (mixer.get_request_pad ("sink_%u"));
mixer.link (encoder);
break;
default:
assert_not_reached ();
}
ユーザーの録音ソースの選択(device_id
の値)に応じて、パイプラインを組み立てます。0ならマイクのみなので、パイプラインにマイク音声のpulsesrc
のみを追加した上で、そのpulsesrc
とエンコーダーをリンクさせます。1ならシステムサウンドのみなので、パイプラインにシステムサウンドのpulsesrc
のみを追加した上で、そのpulsesrc
とエンコーダーをリンクさせます。2ならその両方の音声を取得するので、マイク音声とシステムサウンドのpulsesrc
をミキサーにリンクさせた上で、ミキサーをエンコーダーにリンクさせます。
get_static_pad ("パッド名")
およびget_request_pad ("パッド名")
で、エレメントのパッドを取得できます。パッドの種類の違いで、これら2つを使い分けます。
ドキュメンテーション(例えばpulsesrc
であればここ)の"Pad Templates"→"Presense"が"always"なら、常にあるパッド、つまり静的パッドなので、get_static_pad ("パッド名")
を使ってパッドを取得します。"request"なら、リクエストされたときに生成されるパッド、つまり動的パッドなので、get_request_pad ("パッド名")
を使います。パッド名は、"Pad Templates"以下の見出しです。
muxerでエンコーダーとシンクを仲介する
if (muxer != null) {
pipeline.add (muxer);
encoder.get_static_pad ("src").link (muxer.get_request_pad ("audio_%u"));
muxer.link (sink);
} else {
encoder.link (sink);
}
muxer
を必要とするエンコーダーの場合、先ほどmuxer
のエレメントを作成したので、パイプラインに追加した上でエンコーダーとリンクさせ、muxer
をfilesink
にリンクさせます。muxer
が必要ないエンコーダーの場合は、直接エンコーダーからfilesink
にリンクさせます。
パイプラインの状態を変更
pipeline.get_bus ().add_watch (GLib.Priority.DEFAULT, bus_message_cb);
pipeline.set_state (Gst.State.PLAYING);
最後に、録音中に出力されるメッセージを逐一監視させて、エラー発生時や録音完了時に処理を行えるようにしておきます。パイプラインを再生状態に設定すれば録音が開始されます。
おわりに
今回は、GStreamer/GTK/Valaを使って開発しているサウンドレコーダーアプリについて書かせていただきました。この記事が少しでも面白いと思っていただけたら幸いです。
参考文献
- GStreamer というマルチメディアフレームワーク
- GStreamer アプリケーション開発マニュアル 日本語訳 (0.10.25.1)
- あしたのオープンソース研究所: GStreamer - ククログ(2010-01-11)
- GStreamerのエレメントをつないでパイプラインを組み立てるには - ククログ(2014-07-22)
- GStreamer の gst-launch-1.0 を使ってみる - ふとしのブログ
- Valadoc(Valaを使って開発するのに必須のドキュメンテーションです)
- GStreamerのドキュメンテーション
-
これは、elementary OSのスタイルシートに起因すると考えられます。 ↩