Help us understand the problem. What is going on with this article?

Flutterで音楽の知識がなくても演奏できるアプリを作った

音楽の知識がなくても誰でも曲を演奏したり作曲したりできるアプリ(伴奏のみ)をFlutterで作成しました。

技術的に共有できる部分を紹介していきたいと思います。

使い方

基本的な使い方としては並んでいる大きなボタンをタップするだけです。これで選択したコードが勝手に再生されます。

今回のアプリで重視したのは、音楽について何もわからない人でも、子どもでも誰でも曲が演奏&作曲出来てしまうという部分です。実際にうちの7歳児も適当にボタンを押しながら左右にリズムをとって何やらオリジナルの曲を口ずさんでいたので恐らくこの部分は達成できているのではないかと思います。

並んでいるコードは一つの曲を演奏するのに必要な最低限のコードです。これさえあれば基本的な曲は大体演奏できますし、作ることも出来ます。メイン、サブはどんな順番に使ってもだいたいいい感じの流れになるようになっています。右下の上下ボタンでこの曲調は変更もできます。

演奏の保存

いい感じに演奏ができたら下に「直前の演奏を保存する」ボタンがありますのでそれを実行すれば保存し、後でいつでも再生することが出来ます。

image.png

事前に録音ボタンを押してスタートする必要もないため、ふと思いついた時に必ずすぐ保存することが出来ます。

テンポやストロークの変更

テンポはスライダーでいつでもすぐ変更できます。

image.png

ストロークもいくつかの基本的なストロークパターンから選択することが出来ます。

image.png

あとは跳ねるパターンなど、いくつか追加予定です。最終的にはユーザーが自分で作れるようにできると良いかなと思っています。

作り方

作り方を説明していきます。

音の再生

Flutterでは音の再生はできません。iOS、Androidのネイティブ側と連携させて作成する必要があります。これは普通にドキュメントにもあります。

Writing custom platform-specific code - Flutter

あとは普段使われているFlutterのプラグイン等でネイティブの機能を使っているものなども基本的には同様の方法で作られています。誰もがよく使うネイティブの機能はこのようにプラグインとして作成されて、みんながDart側だけの開発でアプリが作れてしまうようになっています。

ネイティブとの連携方法

具体的には、Dart側にはこのように書きます。MethodChannelに識別子を指定し、invokeMethodでネイティブ側に命令するだけです。

class Player {
  static const platform =
      const MethodChannel('com.example.anyone-composer/midi');

  Future setTempo(int tempo) async {
    await platform.invokeMethod('setTempo', {'tempo': tempo});
  }
}

以下はAndroid、iOSでの受け取り方ですが、これもシンプルです。ドキュメントにあるように型も色々使えます。

Android側の処理

Android側で上記の処理を受け取る例です。

override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    GeneratedPluginRegistrant.registerWith(flutterEngine);

    MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example.anyone-composer/midi").setMethodCallHandler {
        call, result ->
        if (call.method == "setTempo") {
            val tempo: Int? = call.argument<Int>("tempo")
            // nanikasuru
            result.success(true)
        }
    }
}

iOS側の処理

iOS側で処理を受け取る例です。

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let midiChannel = FlutterMethodChannel(name: "com.example.anyone-composer/midi",
                                              binaryMessenger: controller.binaryMessenger)
    midiChannel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
      if call.method == "setTempo" {
        if let map = call.arguments as? Dictionary<String, Int>,
          let tempo = map["tempo"] {
          // nanikasuru
        }
        result(true)
      }
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

音声の再生

sf2という拡張子のサウンドフォントファイルを利用してMidiを再生しています。実はFlutterのプラグインには元々Midiを再生するためのプラグインがあったります。

flutter_midi/FlutterMidiPlugin.java at master · rodydavis/flutter_midi

しかしこれだと今回作成したい機能としては不足していました。例えば音を設定したり、音程を指定したり、音を一斉に停止したりする機能が実装されていなかったりしました。そのためこれを参考に独自に作成していくことにしました。ですので基本的な再生の仕組みについては上記のネイティブ側のコードを見るとわかります。使われている機能のドキュメントやサンプルはほとんどなかったので拡張は苦労しましたが……。

注意点

ネイティブ側に入るとスレッドの概念も出てきます。例えば

重い処理を行う場合だったり……

Flutterのネイティブで重い処理をさせる方法(Android) - Crieit

タイマーなどで非同期処理を行う場合だったり……

Swift マルチスレッドでの同期処理(synchronized)

というのを気をつける必要があります。

再生アニメーションの描画

演奏中、再生中はストローク中の今どこを再生しているかをアニメーションで表示しています。赤いラインが右に流れていくだけのアニメーションです。

image.png

これはFlutterにあるCustomPaintという機能を利用しています。CustomPaintはCustomPainterというクラスをオーバーライドした独自の描画を作成したクラスを渡すことで、Canvasに何でも自由に描画することができるウィジェットです。必要なUIを実現するためのウィジェットが見つからない時はこれで何でも作成できます。

ウィジェットの利用は例えばこんな感じでシンプルです。

child: CustomPaint(
  painter: StrokeAndLinePainter(
    context,
    Data.strokeSets[playerStore.strokeIndex],
    playerStore.tempo,
    timeStore.time,
  ),
),

実際のPainterクラスの描画例です。色々処理が入っているのでごちゃごちゃしていますが、要するに canvas.drawなんちゃら のようなものを使って描画するだけです。

  @override
  void paint(Canvas canvas, Size size) {
    strokes.asMap().forEach((index, velocity) {
      final paint = Paint();
      paint.color = Theme.of(context).accentColor;
      final rect = Rect.fromLTWH(
        horizontalMargin +
            (size.width - horizontalMargin * 2) / strokes.length * index,
        margin,
        barWidth,
        size.height - margin * 2,
      );
      canvas.drawRect(rect, paint);
    });
  }

アニメーションの方法

CustomPaintはあくまでも独自の描画を行うための仕組みであり、アニメーションの機能を提供しているわけではありません。Flutter自体がそもそも状態に応じた描画をするためのものですので、単なる描画ウィジェットでは対応ができません。そのため、アニメーションの処理は自分で作る必要はあります。

具体的にはTimerを使ってstateを更新していっています。まずはタイマーを作成します。

      Timer.periodic(Duration(milliseconds: 16), _onTimer);

色々省略していますが、更新しているところです。時間はネイティブ側から受け取り、それをセットしています。

  Future _onTimer(Timer timer) async {
    final timeStore = Provider.of<TimeStore>(context, listen: false);
    final time = await _player.getTime();
    timeStore.setTime(time);
  }

これにより高頻度で画面全体を更新してしまわないように、専用のStoreを作り、アニメーション部分だけを再描画するようにしています。

  Widget _buildStrokePaint() {
    return Consumer2<PlayerStore, TimeStore>(builder: (
      context,
      playerStore,
      timeStore,
      _,
    ) {
      return CustomPaint(
        painter: StrokeAndLinePainter(
          context,
          Data.strokeSets[playerStore.strokeIndex],
          playerStore.tempo,
          timeStore.time,
        ),
      );
    });
  }

電池消費を抑えられたりと、恐らくユーザーに優しいはず……。他にも色々と同様のことを行う方法はあると思います。

演奏データの保存

演奏データの保存は、実際に音声を保存しているわけではなく、単にコードを変更したタイミングとコードを保存しているだけです。そのため非常にデータとしても軽量で、SQLiteに保存しています。わざわざ録音スタートボタンを押さなくてもいい、というのもとりあえず毎回演奏データをstate上に保存しておけるからです。

再生時にはそのタイミングで音を変えたりしているだけです。ですので、再生時に違うテンポや違うストロークで再生することも可能です。

今回はこんな感じでシンプルなベースモデルを作成し、全てのモデルでこれを使うようにしてみました。

abstract class Model {
  final String table = '';
  int id;

  Map<String, dynamic> toMap();

  Future insert() async {
    final db = await DbProvider.instance.database;
    id = await db.insert(table, toMap());
  }

  Future update() async {
    final db = await DbProvider.instance.database;
    await db.update(table, toMap(), where: 'id = ?', whereArgs: [id]);
  }

  Future delete() async {
    final db = await DbProvider.instance.database;
    await db.delete(table, where: 'id = ?', whereArgs: [id]);
  }
}

実際に使うモデルはこれを継承し、tableを指定してtoMapを実装します。あとは下記のような感じでデータを操作できるようになります。

Record record = Record();
record.name = 'hoge';
await record.insert();

record.name = 'hoge2';
await record.update();

await record.delete();

本格的な業務アプリだときついかもしれませんが、これくらいの小さいアプリなら十分汎用的で楽になると思います。

今後の展望

下記のような機能も作っていきたいと思います。どれも大変そうですので時間は掛かりそうですが。

  • 音声を録音して演奏に乗せられるように
  • ストロークを自分でカスタマイズできるように
  • 保存した演奏を編集できるように

まとめ

以上、Flutterで実際にアプリを作ってリリースしてみた話です。ネイティブ連携、CustomPaintとちょっと面白いものも使えたので今後も色々とできることの幅が広がった気がします。

今回はメインの機能がネイティブに依存しているため、そもそも両方ネイティブで開発したほうがいいのでは、とも思いました。しかしUI部分を全部共通にできるのはやはり大きなメリットであり、個人での開発ではやはりかなりの工数削減につながったと思います。

今後もFlutterでアプリを100個目指して作っていきたいと思います。

何か参考になる部分などあればぜひLGTMよろしくおねがいします!

dala00
Qiitaのようだけどポエムでも何でも書けるサービスを運営しています。 https://crieit.net 個人開発もしてるWebエンジニアです。業務依頼、同業種の方からのコンタクトなどお気軽に。業務経験有:PHP, MySQL, Laravel, Vue.js, Go, RoR 趣味サービス:Flutter, React, Next.js, Nuxt.js, Phoenix等色々
https://crieit.net/users/dala00
admin-guild
「Webサービスの運営に必要なあらゆる知見」を共有できる場として作られた、運営者のためのコミュニティです。
https://admin-guild.slack.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした