LoginSignup
58
33

More than 3 years have passed since last update.

遠隔演奏できるピアノアプリをFlutterとgRPCで作った

Last updated at Posted at 2019-12-13

Flutter Advent Calendar 2019 の 13 日目の記事です。

gRPC の使い方の記事が Flutter というより Dart の話になってしまったので、こちらはその副産物のアプリをお見せする記事になりました。
アプリはお遊びですが、工夫したところなどが誰かの参考になればと思います。

こんなものができました

GitHub - kaboc/flutter_remote_piano: A piano app made with Flutter as a gRPC example

midi で音を出すピアノアプリです。
同じサーバに繋いだ複数台の間で演奏を伝えて再現させることができる という、我ながらなかなか面白いものができました。

remote_piano.gif

これは、自分で演奏したときと同様のデータを gRPC で送って自動演奏させたときのものです。
GIF だと音がないので、Twitter に上げた動画もご覧ください。

使ったものなど

gRPC

gRPC なしでは難しかったと思います。
Dart が gRPC に対応しているのはありがたいことです。

概要や使い方をまとめた記事を本日上げましたので、よろしければ参考になさってください。
    ↓
DartでgRPCを使う
「DartでgRPCを使う」より:Bidirectional streaming RPCsの画像

flutter_midi

モバイルと Web に対応した MIDI または MP3 再生のプラグインパッケージがありませんでしたので、モバイルには flutter_midi、Web には JavaScript のライブラリを使いました。

flutter_midi は Android では音が出るまで少し ラグがある(iOS は未確認)ので避けたいところですが、MP3 だと一音ずつのファイルを用意しないといけなくて手間がかかるので MIDI にしました。

MIDI もサウンドフォントというものは必要ですが、やむを得ません。
無料のものがネット上で公開されていたりします。1 2

また、API リファレンス には「0-256 Multiple notes can be played」と書かれているにも関わらず、ほぼ半分の 127 までしか使えませんでした。
更に、120 前後の高音を鳴らすとこだまのように数回鳴ってしまう現象もあります。
flutter_midi の不具合なのかサウンドフォントに原因があるのかわかりません。3

Tone.js

Web で使った JavaScript のライブラリは Tone.js です。

flutter_midi より数オクターブ高く聞こえます。
もし同じ高さにするとしたら、flutter_midi で使うサウンドフォントを変えて合わせるか、Tone.js で何オクターブか下げるしかないと思います。
本アプリではそのような調整はしていません。

状態管理、DI など

  • Provider
    • Widget ツリーの下層で使う様々なもの(BLoC など)を生成して渡すのに使用
  • ChangeNotifierProvider
    • TextField の入力値を管理する TextEditingController に使用
  • BLoC
    • サーバとの接続/切断状態とそのボタン表示の管理
    • 鍵盤タップ時のエフェクトと音再生をトリガー、およびサーバに送信(gRPC のメソッドを実行)
    • 他のクライアントでの鍵盤タップ情報をサーバから受信して自アプリで表示や音を再現

環境依存のあるものを BLoC 内で生成して使うようなことはなるべく避けています。
gRPC 関連もモバイルと Web で少し処理が異なるので、抽象クラスを用意しておいて実装を注入するようにしました。

intl

日本語・英語・中国語の多言語対応もしました。
その際には下記を参考にしました。

コード

Provider 関連

Provider と ChangeNotifier でデータを渡そうとしている箇所は下記のようになっています。

main.dartより
home: MultiProvider(
  providers: [
    Provider<Sound>(
      create: (_) => Sound()..init(),
    ),
    Provider<RemoteBloc>(
      create: (context) {
        final sound = Provider.of<Sound>(context, listen: false);
        return RemoteBloc(
          grpc: Grpc(),
          basePitch: SoundBase.basePitch,
          numberOfKeys: SoundBase.whiteNum + SoundBase.blackNum,
        )..pitch.listen((pitch) => sound.play(pitch));
      },
      dispose: (_, bloc) => bloc.dispose(),
    ),
    ChangeNotifierProvider<HostController>(
      create: (_) => HostController(text: AppSettings.defaultHost),
    ),
    ChangeNotifierProvider<PortController>(
      create: (_) => PortController(text: AppSettings.defaultPort),
    ),
  ],
  child: ...,
),
widget/piano.dartより
body: SafeArea(
  child: LayoutBuilder(
    builder: (context, constraints) {
      final maxWidth = constraints.maxWidth;
      final maxHeight = constraints.maxHeight;

      return Provider<Size>.value(
        value: Size(maxWidth, maxHeight),
        child: ...,
      );
    },
  ),
),

RemoteBloc のインスタンス生成直後に、鍵盤タップの listen を開始しています。
create の関数は State クラスの initState() のように一度しか呼ばれないので、何度も listen してしまうのを防げます。

HostController と PortController は TextEditingController の別名の型として用意したものです。
provider で同じ型のデータを複数渡すには工夫が必要で、その工夫の一つとして別名を使いました。
(厳密には別名ではなく、同じ機能を持つ新たなクラス/型を Mixin 適用によって作ったものです。)

この件については以前に記事を書いています。
【Dart】シンプルに派生クラスを作る(別名の代わり) - のんびり精進

私独自のやり方であり、良い方法なのかわかりません。
渡すものが Notifier 系でなければ tuple や List 等を使う方法もあります。

UI

UI は不勉強で今のところ苦手なので強引にやりましたが、割とすぐにできました。

白鍵・黒鍵

widget/white_key.dartより
Expanded(
  child: GestureDetector(
    child: StreamBuilder<bool>(
      stream: bloc.tapped(note.index),
      initialData: false,
      builder: (context, snapshot) {
        return Container(
          decoration: BoxDecoration(
            color: snapshot.data ? Colors.grey : Colors.white,
            border: const Border.fromBorderSide(
              BorderSide(style: BorderStyle.solid),
            ),
            borderRadius: const BorderRadius.only(
              bottomLeft: Radius.circular(8.0),
              bottomRight: Radius.circular(8.0),
            ),
          ),
          ...,
        );
      },
    ),
    onTapDown: (_) => bloc.play(pitch),
  ),
)
widget/black_key.dartより
GestureDetector(
  child: StreamBuilder<bool>(
    stream: bloc.tapped(note.index),
    initialData: false,
    builder: (context, snapshot) {
      return Container(
        width: width * 0.75,
        height: height * 0.6,
        margin: EdgeInsets.symmetric(horizontal: width * 0.125),
        decoration: BoxDecoration(
          color: snapshot.data ? Colors.grey : Colors.black,
          borderRadius: const BorderRadius.only(
            bottomLeft: Radius.circular(8.0),
            bottomRight: Radius.circular(8.0),
          ),
        ),
      );
    },
  ),
  onTapDown: (_) => bloc.play(pitch),
)

白鍵は勝手に等幅になるように Expanded() を使いました。
一方、黒鍵は画面(SafeArea)のサイズを白鍵の数で割った幅にしています。
幅を白鍵の 75 %にしたいので、左右 12.5 %ずつを margin にしています。

Material Design の Ripple Effect を使わずに StreamBuilder によって自力で色を変えているのは、ローカルとリモートクライアントでのタップを Stream で受け取ってエフェクト表示したいからです。

blocs/remote_bloc.dartより
for (int i = 0; i < numberOfKeys; i++) {
  _tapControllers.add(StreamController<bool>());
}

このように RemoteBloc のコンストラクタで 17 鍵分の StreamController を用意していますが、一つの Stream にしておいて where() でイベントを絞って受け取ることもできるのではないかと思います。
あるいは、ChangeNotifier を継承した一つにクラスに 17 鍵全てを担当させ、各鍵の Widget で SelectorshouldRebuild によってリビルドを制御することもできそうです。

鍵盤全体

widget/piano.dartより
Stack(children: <Widget>[
  Row(
    children: const <Widget>[
      WhiteKey(Notes.c1),
      WhiteKey(Notes.d1),
      WhiteKey(Notes.e1),
      WhiteKey(Notes.f1),
      WhiteKey(Notes.g1),
      WhiteKey(Notes.a1),
      WhiteKey(Notes.b1),
      WhiteKey(Notes.c2),
      WhiteKey(Notes.d2),
      WhiteKey(Notes.e2),
    ],
  ),
  Positioned(
    left: keyWidth / 2,
    top: 0.0,
    right: null,
    bottom: null,
    child: Row(
      children: const <Widget>[
        BlackKey(Notes.cm1),
        BlackKey(Notes.dm1),
        BlackKeySpace(),
        BlackKey(Notes.fm1),
        BlackKey(Notes.gm1),
        BlackKey(Notes.am1),
        BlackKeySpace(),
        BlackKey(Notes.cm2),
        BlackKey(Notes.dm2),
      ],
    ),
  ),
])

白鍵は単純に並べ、黒鍵は Positioned() による絶対位置指定で白鍵の半分の大きさだけずらした位置から並べただけです。
黒鍵はミとファの間、シとドの間にはないので、白鍵と同じ幅の SizedBox() をラップした BlackKeySpace() という Widget で埋めています。

こう見ると簡単ですが、自分で作った際には不慣れで少し悩みました。

リモート接続ボタン

common/connection_states.dart
enum ConnectionStates {
  off,
  connecting,
  ready,
}
widget/connection_button.dartより
StreamBuilder<ConnectionStates>(
  stream: bloc.state,
  initialData: ConnectionStates.off,
  builder: (context, snapshot) {
    return IconButton(
      icon: _icons[snapshot.data.index],
      onPressed: () async {
        snapshot.data == ConnectionStates.ready
            ? await bloc.disconnect()
            : ConnectionDialog(context: context).show();
      },
    );
  },
)

gRPC によるサーバ接続の状態が変わると BLoC 内で _stateController という StreamController によって接続状態を表す enum のインデックスが Stream<ConnectionStates> に流れます。
それを StreamBuilder() で受け取り、接続状態の変化に合わせてリビルドされてボタンのアイコンと色を変えています。

音再生関連

複数のプラットフォームに対応できるように、ベースの処理を抽象クラスで定義しておいて、その実装を DI するようにしました。

platforms/sound_base.dart(抽象クラス)より
abstract class SoundBase {
  static const basePitch = 60;
  static const whiteNum = 10;
  static const blackNum = 7;

  void init();

  void play(int pitch);

  static int toPitch(Notes note) {
    return basePitch + note.index;
  }

  ...
}

掲載はしませんが、各鍵のインデックス(0 ~ 16)の enum や音名のリストも SoundBase と同じファイルに置いています。
enum は連番を振るくらいしかできなくて白鍵と黒鍵を区別してそれぞれの数を取り出しにくいので、マジックナンバーで指定しています。

platforms/mobile/sound.dart(モバイル用具象クラス)
class Sound extends SoundBase {
  @override
  void init() {
    FlutterMidi.unmute();
    rootBundle.load(_fontPath).then((sf2) => FlutterMidi.prepare(sf2: sf2));
  }

  @override
  void play(int pitch) {
    FlutterMidi.playMidiNote(midi: pitch);
  }
}
platforms/web/sound.dart(Web用具象クラス)
class Sound extends SoundBase {
  dynamic _synth;

  @override
  void init() {
    // ignore: avoid_as
    _synth = JsObject(context['Tone']['PolySynth'] as JsFunction)
        .callMethod('toMaster');
  }

  @override
  void play(int pitch) {
    final octaveNum = (pitch / 12).floor();
    final noteName = '${SoundBase.toName(pitch)}$octaveNum';

    _synth.callMethod('triggerAttackRelease', [noteName, '8n']);
  }
}

音を鳴らす play() は各鍵のインデックス(0 ~ 16)ではなくピッチ(0 ~ 127)を使うようにしています。
これによって、インデックス範囲外の音がサーバから送信されてきても鳴らすことができます。

gRPC 関連

ようやく gRPC です。
これがあってこそ、このアプリができました。

proto ファイル

piano.proto
syntax = "proto3";

package piano;

service Piano {
    rpc Connect (stream Note) returns (stream Note) {}
}

message Note {
    uint32 pitch = 1;
}

Bidirectional streaming RPCs です。
リクエストとレスポンスでやり取りする対象がどちらも音のピッチを表す数値なので、その情報を持った Note という型を双方向で使います。

モバイルと Web

モバイルでは grpc-dart、Web では gRPC-Web を使います。

「DartでgRPCを使う」の記事の Web のところ にも書きましたが、Web では Bidirectional streaming に対応していません。
Server streaming には対応していて、それに相当する単方向の動作になってしまいます。

送信に Unary、受信に Server streaming を使えば双方向に近い動作を実現できなくもなさそうですが、gRPC の学習の副産物としては複雑になりすぎるので、gRPC-Web の制限を受け入れて割り切って使いました。
そのため、Web 版は次のような残念な動作になっています。

  • 鍵盤のどこかを一度タップしないとサーバからの受信が開始されません
  • 送信は最初の一音しかできず、二音目以降はブラウザのコンソールにエラーが出力されます

注意

2020/6/19
dev チャネルの Flutter 1.20.0-0.0.pre で久々に実行してみたところ、Web では接続後最初の一音を送信した直後にサーバから切断される現象が起こりました。
以前はそうならなかったので、Flutter の何らかの変更が影響しているのかもしれません。
Web に gRPC を本格導入する前に検証用コードを書くなどして試すことをお勧めします。

サーバ側

ここでいきなり Go のコードです。
触ったことがない方、すみません!
直前まで Dart で粘りましたが、ブロードキャストの実現が(私には)できませんでした。

Dart で実現できたので Go のコードは折りたたんでおきます。

Go で書いたサーバ(クリックで開閉)
func (*pianoService) Send(stream pb.Piano_SendServer) error {
    // 接続データ(ServerStream 等)を sync.Map に保管
    streams.Store(stream, nil)
    defer func() {
        // 切断したら sync.Map から削除
        streams.Delete(stream)
        log.Println("Disconnect", &stream)
    }()

    log.Println("Connected", &stream)

    for {
        // クライアントから受信
        note, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }

        fmt.Println(note.Pitch)

        if note.Pitch > 127 {
            log.Println("pitch out of range")
            continue
        }

        // 受信したピッチのデータを保管している接続先分ループして送信
        streams.Range(func(key, _ interface{}) bool {
            // ただし今受信した接続先は除く
            if key != stream {
                s := key.(pb.Piano_SendServer)
                err = s.Send(&pb.Note{Pitch: note.Pitch})
                if err != nil {
                    log.Printf("error while broadcasting: %v", err)
                }
            }
            return true
        })
    }
}

さて、Dart によるサーバは下記です。
Service のメソッド以外は省略しています。

Dartで書き直したサーバ
class PianoService extends PianoServiceBase {
  // 各クライアント分のStreamを入れておくMap
  final _controllers = <StreamController<Note>, void>{};

  @override
  Stream<Note> connect(ServiceCall call, Stream<Note> request) async* {
    print('Connected: #${request.hashCode}');

    // 当該クライアント分のStreamControllerを用意してMapに追加
    final clientController = StreamController<Note>();
    _controllers[clientController] = null;

    request.listen((req) {
      print('Request from #${request.hashCode}: ${req.pitch}');

      if (req.pitch > 127) {
        print('Pitch out of range');
        return;
      }

      // リクエストが来たら
      // 当該以外の全クライアントのStreamに流す
      _controllers.forEach((controller, _) {
        if (controller != clientController) {
          controller.sink.add(req);
        }
      });
    }).onError((dynamic e) {
      print(e);

      _controllers.remove(clientController);
      clientController.close();
      print('Disconnected: #${request.hashCode}');
    });

    // 他クライアント分のリクエストが流れてきたら
    // 受け取った側のクライアント分のレスポンスをする
    await for (final req in clientController.stream) {
      yield Note()..pitch = req.pitch;
    }
  }
}

サーバのリポジトリはこちらです。

クライアント側

Flutter のアプリとしてではなく Dart で CLI として実装すると次のようになります。
不正な値が入力される可能性を無視してシンプルにした場合のコードです。

Future<void> main() async {
  final channel = ClientChannel(/* 省略 */));
  final client = PianoClient(channel);
  final responses = client.connect(requestStream());

  try {
    // 受信するたびに処理する
    await for (final res in responses) {
      print(res.pitch);
    }
  } catch (e) {
    print(e);
  }

  await channel.shutdown();
}

Stream<Note> requestStream() async* {
  while (true) {
    // 標準入力のデータが Stream に入る
    final lines = stdin.transform(utf8.decoder).transform(const LineSplitter());

    // データを Stream から取り出して gRPC で送信
    await for (final line in lines) {
      yield Note()..pitch = int.tryParse(text);
    }
  }
}

アプリでは下記のとおり上記より複雑に見えますが、やっているのは基本的に同じです。
音関連と同様に複数のプラットフォームに対応するために抽象クラスを使っています。

platforms/grpc_base.dart(抽象クラス)
abstract class GrpcBase<Channel> {
  Channel channel;
  PianoClient client;
  final _requestController = StreamController<int>.broadcast();

  void init({@required String host, @required int port});

  Future<void> connect({ResponseHandler onResponse, ErrorHandler onError});

  Future<void> terminate();

  // データを Stream から取り出して gRPC で送信
  Stream<Note> requestStream() async* {
    await for (final pitch in _requestController.stream) {
      yield Note()..pitch = pitch;
    }
  }

  // 送信対象のデータを Sink に追加して Stream に流す
  void addRequest(int pitch) {
    if (channel != null) {
      _requestController.sink.add(pitch);
    }
  }

  ...
}

実装のほうはモバイルの分だけ載せておきます。
注入は、Provider 関連 のところに載せたように main.dart 内で RemoteBloc のコンストラクタに対して行っています。

platforms/mobile/grpc.dart(モバイル用具象クラス)
class Grpc extends GrpcBase<ClientChannel> {
  @override
  void init({@required String host, @required int port}) {
    channel = ClientChannel(/* 省略 */);
    client = PianoClient(channel);
  }

  @override
  Future<void> connect({ResponseHandler onResponse, ErrorHandler onError}) async {
    final responses = client.connect(requestStream());

    try {
      // 受信するたびに処理する
      await for (final res in responses) {
        onResponse(res.pitch);
      }
    } catch (e) {
      print(e);
      if (e.toString().contains('SocketException')) {
        await onError();
        throw ConnectionFailureException();
      }
    }

    await onError();
  }

  ...
}

受信時に行う処理は、connect() を呼ぶときにコールバックとして渡します。

blocs/remote_bloc.dartより
await grpc.connect(
  onResponse: (pitch) => _executeTap(pitch),
  onError: () async => await disconnect(),
);

モバイルではサーバや接続関連の例外を次のように処理しています。

platforms/mobile/grpc.dartより(モバイルでの例外処理)
on GrpcError catch (e) {
  await onError();
  if (e.code == StatusCode.unavailable) {
    throw ConnectionFailureException();
  }
} catch (e) {
  if (e.toString().contains('SocketException')) {
    await onError();
  }
}

SocketException のときだけ再度例外をスローして接続ダイアログのところで捕捉しなおしてエラーダイアログを表示したいのですが、ここに来たときには

gRPC Error (14, Error connecting: SocketException: OS Error: Connection timed out, errno = 110, address = 192.168.xx.xx, port = 46702)

のように gRPC Error になっていて on SocketException catch で捕らえることができませんでした。代わりの判別方法として「SocketException」という文字列が含まれるか確認しています。

Web では例外の発生の仕方や種類がモバイルと異なっているようでしたので、上のような処理は省いています。


2020/6/19 訂正

例外は SocketException ではなく GrpcError だと気づき、コードを改善しました。
SocketException は GrpcError が持つ情報の一部でした。

エラーコードは e.code に入っていて、接続関連エラーなら 14 になります。
また、14 に対応する StatusCode.unavailable という定数と比較することで判断できます。
ただし、接続がタイムアウトしたときには 4(StatusCode.deadlineExceeded)になるなど、原因によって変わる場合があります。
プラットフォームによって異なる可能性もあります。

複数のケースを少し見てみたところ、接続できなかった場合とサーバから切断された場合はどちらも 14 となり、e.message の文言のちょっとした違いで区別されるようでした。
実際のコードやメッセージの情報を確認しながら判断処理を書くようにしましょう。

モバイルと Web で例外発生の仕方が異なるのはもともと書いていたとおりですので、そちらもご注意ください。

おわりに

こんなアプリが簡単に作れる Dart / Flutter は素晴らしいですね。
開発に何日もかからず、楽しみながら取り組めました。
gRPC もとても便利&楽で、今後もっと使っていきたくなりました。

このような記事で Dart / Flutter に興味を持つ人が増えるといいなと思います。
東京ばかりでなく各地方もコミュニティが広がってほしいですね。


  1. 私は「piano.sf2」でウェブ検索し、国内の有名なソフトダウンロードサイトからダウンロードしました。他にもいくつか試してみると、音の大きさなどがそれぞれ微妙に違っていました。 

  2. 10 MB 弱から数百 MB のものまであり、もし配布するならサイズが気になります。使用条件も注意が必要です。 

  3. 他の問題として、(記事公開前日の Flutter 最新バージョンでは起こりませんでしたが)その前のバージョンではデバッグビルドで起動時に読み込みエラーのようなものが起こり、Hot Restart するまで音が出ないという現象がありました。 

58
33
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
58
33