9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ひとり開発Advent Calendar 2019

Day 20

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

Last updated at Posted at 2019-12-19

ひとり開発 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を使っていれば、以下のボタンをクリックすると、このアプリをインストールできます。

AppCenterで入手

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

Flathub で入手

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

開発のきっかけ

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

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

ボタンの視認性の問題

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

開発方針

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

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

GNOMEサウンドレコーダーのUI

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

あとは、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の全体像

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はファイルを保存するエレメントです。

パイプライン、マイク用およびシステムサウンド用pulsesrc、ファイルシンクを作成

録音デバイスを取得

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の有無で処理を分ける必要があるためです。

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"以下の見出しです。

マイク用およびシステムサウンド用pulsesrcをパイプラインに追加し、適宜mixerを介してリンクさせる

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のエレメントを作成したので、パイプラインに追加した上でエンコーダーとリンクさせ、muxerfilesinkにリンクさせます。muxerが必要ないエンコーダーの場合は、直接エンコーダーからfilesinkにリンクさせます。

GStreamerの全体像

パイプラインの状態を変更

pipeline.get_bus ().add_watch (GLib.Priority.DEFAULT, bus_message_cb);
pipeline.set_state (Gst.State.PLAYING);

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

おわりに

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

参考文献

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

9
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?