GStreamer/GTK/Valaでサウンドレコーダーアプリを作ってみた

[ひとり開発 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/solo-developer)、20日目の記事です。

2018年11月から、GStreamer/GTK/Valaを使ったサウンドレコーダーアプリを個人で開発しているのですが、それに関する話を書きます。

# こんなアプリを開発しています
![スクリーンキャスト](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/d51a0fe5-dd84-eecf-2209-8c6e57fce1af.gif)

Linux上で動作するサウンドレコーダーアプリです。主に、Ubuntu LTSベースの[elementary OS](https://elementary.io/)での利用を想定していますが、後述するようにほかのディストリビューションでも利用可能です。

## 対応機能

* カウントダウン機能（最大15秒）
* 録音時間の指定（最大600秒）
* 録音ソース（マイク・システムサウンド・両方）の指定
* 対応するファイル形式は、AAC・FLAC・MP3・Ogg Vorbis・Opus・WAV
* 自動保存機能（録音終了時およびアプリ終了時）
* 状態保存機能（最後にアプリを終了した時の録音の設定やウィンドウの位置を、次回起動時に復元）

## インストール
elementary OSを使っていれば、以下のボタンをクリックすると、このアプリをインストールできます。

[![AppCenterで入手](https://appcenter.elementary.io/badge.svg)](https://appcenter.elementary.io/com.github.ryonakano.reco)

【2022年10月29日追記】elementary OS以外であれば、Flathubからのインストールがおすすめです。

<a href="https://flathub.org/apps/com.github.ryonakano.reco"><img src="https://flathub.org/assets/badges/flathub-badge-en.svg" width="160" alt="Flathub で入手"></a>

* [ソースコード](https://github.com/ryonakano/reco)

有志の方が、ほかのディストリビューション向けにパッケージを提供してくださっているみたいです。ただ、私はこれらの管理には関与していないので、多少古いバージョンである可能性があります。

* [Arch Linux向け](https://aur.archlinux.org/packages/reco)
* [OpenSUSE向け](https://software.opensuse.org/package/reco)

# 開発のきっかけ
ある時、大学での話し合いで簡単な議事録（といっても、メモ程度ですが）を取ることになりました。その時、elementary OSをインストールしたノートPCを使っていたのですが、話し合いが速く進みすぎて、あまり議事録を取れませんでした。そこで、サウンドレコーダーアプリを使って話し合いを録音して、話し合いの場で聞きそびれたことをあとから聞き直すことにしました。

最初は、GNOMEサウンドレコーダーを使おうと思ったのですが、あまりUIが気に入りませんでした。加えて、録音したファイルにフォーカスが合うと、情報ボタンや削除ボタンが見づらくなるという問題がありました[^1]（下図）。

![ボタンの視認性の問題](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/60c771a3-2bc9-f5c2-f69e-0967fee01608.gif)

ということで、車輪の再発明になることを承知の上で、自分でサウンドレコーダーアプリを作ってみることにしました。

# 開発方針
全体的に、GNOMEサウンドレコーダーを意識して開発しました。例えば、GNOMEサウンドレコーダーで録音可能なファイル形式は、このアプリでも録音可能です。

一方、GNOMEサウンドレコーダーを含めたほとんどのサウンドレコーダーアプリが、音声の録音・再生どちらも対応しているのに対し、**あえて「音声を録音する」という機能のみに特化させ、再生は再生アプリに任せるという方針にしました。**

![GNOMEサウンドレコーダーのUI](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/4c47d511-19df-02d1-162d-f13dd34fe1f1.png)

一般的なサウンドレコーダーアプリは、ウィンドウ内に録音した音声の一覧を表示させるため、録音形式や録音先の設定は別の設定ウィンドウで行うことが多いようです。変更することが多い設定を深い階層に閉じ込めるのは使いづらいと感じたので、あえてこのような方針を選びました。

あとは、elementary OS向けに開発するということで、[elementary OSのHuman Interface Guidlines](https://docs.elementary.io/hig)と、[elementary OSのコーディングスタイル](https://docs.elementary.io/develop/writing-apps/code-style)になるべく準拠するように心がけました。

# 何を使って作るか
## バックエンド：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の全体像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/94f64162-5e7f-c057-efc0-4b2efbf0788f.png)

GStreamerを使用したコーディングの流れは以下のとおりです。

* **パイプライン**と**エレメント**を作成する（`Gst.ElementFactory.make ("エレメントの種類", "任意の名前")`）
* エレメントをパイプラインに追加する（`パイプラインの変数名.add (エレメント名)`、複数なら`パイプラインの変数名.add_many (エレメント名, エレメント名, …)`）
* ほかのエレメントと**パッド**や**シンク**と呼ばれる属性を利用してリンクさせる

では、解説していきます。なお、以下のコードでは説明向けに多少コードを改変してあります。

## 下準備
```vala
// 実際には以下の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を介しているのは、次回起動時に前回選択した項目を復元するためです。

![録音ソースの選択画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/8ee125df-ef9a-8f2e-bf36-db5edf58d2fa.png)

## パイプラインとエレメントを作成
```vala
// インスタンスメンバーなので、実際には以下の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`はファイルを保存するエレメントです。

![パイプライン、マイク用およびシステムサウンド用pulsesrc、ファイルシンクを作成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/2aff3f5c-5688-9ab4-b2c8-0206ea015ce8.png)

## 録音デバイスを取得
```vala
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という属性](https://gstreamer.freedesktop.org/documentation/pulseaudio/pulsesrc.html?gi-language=c#pulsesrc:device)に結びつけます。ちなみに、録音デバイスを取得するところは、[elementary OS向けのスクリーンキャストアプリのソースコード](https://github.com/artemanufrij/screencast/blob/63f18abd0496b22890d5f89743b8dba130073c86/src/MainWindow.vala#L615-L626)を参考にしました。

## エンコーダーとmuxerを作成
```vala
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`の有無で処理を分ける必要があるためです。

![muxerを追加](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/8a777e74-361f-3a47-f8c1-4a081e50a7e5.png)

## 保存先を設定
```vala
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`以下に録音中のファイルを保存し、録音が完了したらユーザーの設定した保存先にファイルを移動するという方針で開発しているため、ここでは一時的な保存先を指定するにとどまっています。

## エレメントをパイプラインに追加し、リンクさせる（＆ミキサーを設定）
```vala
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 ("パッド名")`](https://valadoc.org/gstreamer-1.0/Gst.Element.get_static_pad.html)および[`get_request_pad ("パッド名")`](https://valadoc.org/gstreamer-1.0/Gst.Element.get_request_pad.html)で、エレメントのパッドを取得できます。パッドの種類の違いで、これら2つを使い分けます。

ドキュメンテーション（例えば`pulsesrc`であれば[ここ](https://gstreamer.freedesktop.org/documentation/pulseaudio/pulsesrc.html?gi-language=c)）の"Pad Templates"→"Presense"が"always"なら、常にあるパッド、つまり静的パッドなので、`get_static_pad ("パッド名")`を使ってパッドを取得します。"request"なら、リクエストされたときに生成されるパッド、つまり動的パッドなので、`get_request_pad ("パッド名")`を使います。パッド名は、"Pad Templates"以下の見出しです。

![マイク用およびシステムサウンド用pulsesrcをパイプラインに追加し、適宜mixerを介してリンクさせる](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/e5e86b8f-c66f-284a-968c-68a302937ae9.png)

## muxerでエンコーダーとシンクを仲介する
```vala
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`にリンクさせます。

![GStreamerの全体像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/292557/94f64162-5e7f-c057-efc0-4b2efbf0788f.png)

## パイプラインの状態を変更
```vala
pipeline.get_bus ().add_watch (GLib.Priority.DEFAULT, bus_message_cb);
pipeline.set_state (Gst.State.PLAYING);
```

最後に、録音中に出力されるメッセージを逐一監視させて、エラー発生時や録音完了時に処理を行えるようにしておきます。パイプラインを再生状態に設定すれば録音が開始されます。

# おわりに
今回は、GStreamer/GTK/Valaを使って開発しているサウンドレコーダーアプリについて書かせていただきました。この記事が少しでも面白いと思っていただけたら幸いです。

# 参考文献
* [GStreamer というマルチメディアフレームワーク](https://qiita.com/tetsukuz/items/e67be5f3bf5b6ca6b625)
* [GStreamer アプリケーション開発マニュアル 日本語訳 (0.10.25.1)](http://oss.infoscience.co.jp/gstreamer/)
* [あしたのオープンソース研究所: GStreamer - ククログ(2010-01-11)](https://www.clear-code.com/blog/2010/1/11.html)
* [GStreamerのエレメントをつないでパイプラインを組み立てるには - ククログ(2014-07-22)](https://www.clear-code.com/blog/2014/7/22.html)
* [GStreamer の gst-launch-1.0 を使ってみる - ふとしのブログ](https://fut-nis.hatenadiary.jp/entry/20121103/1351929786)
* [Valadoc](https://valadoc.org/)（Valaを使って開発するのに必須のドキュメンテーションです）
* [GStreamerのドキュメンテーション](https://gstreamer.freedesktop.org/documentation/?gi-language=c)

[^1]: これは、elementary OSのスタイルシートに起因すると考えられます。