Flutter ゲームにサウンドと音楽を追加する

1. 始める前に

ゲームは視聴覚体験のようなもので、Flutter は、美しいビジュアルと堅実な UI を構築するための優れたツールなので、ビジュアルの側面を深く理解できます。余った構成要素は音声です。この Codelab では、flutter_soloud プラグインを使用して、低レイテンシのサウンドと音楽をプロジェクトに導入する方法を学びます。すぐに興味深い部分にたどり着けるように、基本的なスキャフォールドから始めます。

ヘッドフォンの手描きイラスト。

ここで学習した内容を活用して、ゲームだけでなくアプリにも音声を追加できます。しかし、サウンドと音楽はほぼすべてのゲームに必要ですが、ほとんどのアプリでは不要なため、この Codelab ではゲームに焦点を当てます。

前提条件

  • Flutter に関する基本的な知識。
  • Flutter アプリを実行してデバッグする方法に関する知識

学習内容

  • ワンショット サウンドを再生する方法。
  • ギャップレス音楽ループを再生、カスタマイズする方法。
  • サウンドをフェードイン / フェードアウトする方法
  • 音に環境効果を適用する方法
  • 例外に対処する方法
  • これらすべての機能を 1 つのオーディオ コントローラにカプセル化する方法

必要なもの

  • Flutter SDK
  • 好みのコードエディタ

2. 設定

  1. 次のファイルをダウンロードします。接続速度が遅くても心配はいりません。実際のファイルは後で必要になるため、作業中にそれらのファイルをダウンロードできます。
  1. 任意の名前で Flutter プロジェクトを作成します。
  1. プロジェクトに lib/audio/audio_controller.dart ファイルを作成します。
  2. ファイルに次のコードを入力します。

lib/audio/audio_controller.dart

import 'dart:async';

import 'package:logging/logging.dart';

class AudioController {
  static final Logger _log = Logger('AudioController');

  Future<void> initialize() async {
    // TODO
  }

  void dispose() {
    // TODO
  }

  Future<void> playSound(String assetKey) async {
    _log.warning('Not implemented yet.');
  }

  Future<void> startMusic() async {
    _log.warning('Not implemented yet.');
  }

  void fadeOutMusic() {
    _log.warning('Not implemented yet.');
  }

  void applyFilter() {
    // TODO
  }

  void removeFilter() {
    // TODO
  }
}

ご覧のとおり、これは今後の機能に向けた骨組みにすぎません。そのすべてを、この Codelab で実装します。

  1. 次に、lib/main.dart ファイルを開き、内容を次のコードに置き換えます。

lib/main.dart

import 'dart:developer' as dev;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';

import 'audio/audio_controller.dart';

void main() async {
  // The `flutter_soloud` package logs everything
  // (from severe warnings to fine debug messages)
  // using the standard `package:logging`.
  // You can listen to the logs as shown below.
  Logger.root.level = kDebugMode ? Level.FINE : Level.INFO;
  Logger.root.onRecord.listen((record) {
    dev.log(
      record.message,
      time: record.time,
      level: record.level.value,
      name: record.loggerName,
      zone: record.zone,
      error: record.error,
      stackTrace: record.stackTrace,
    );
  });

  WidgetsFlutterBinding.ensureInitialized();

  final audioController = AudioController();
  await audioController.initialize();

  runApp(
    MyApp(audioController: audioController),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({required this.audioController, super.key});

  final AudioController audioController;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter SoLoud Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.brown),
        useMaterial3: true,
      ),
      home: MyHomePage(audioController: audioController),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.audioController});

  final AudioController audioController;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  static const _gap = SizedBox(height: 16);

  bool filterApplied = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter SoLoud Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            OutlinedButton(
              onPressed: () {
                widget.audioController.playSound('assets/sounds/pew1.mp3');
              },
              child: const Text('Play Sound'),
            ),
            _gap,
            OutlinedButton(
              onPressed: () {
                widget.audioController.startMusic();
              },
              child: const Text('Start Music'),
            ),
            _gap,
            OutlinedButton(
              onPressed: () {
                widget.audioController.fadeOutMusic();
              },
              child: const Text('Fade Out Music'),
            ),
            _gap,
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                const Text('Apply Filter'),
                Checkbox(
                  value: filterApplied,
                  onChanged: (value) {
                    setState(() {
                      filterApplied = value!;
                    });
                    if (filterApplied) {
                      widget.audioController.applyFilter();
                    } else {
                      widget.audioController.removeFilter();
                    }
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
  1. 音声ファイルがダウンロードされたら、プロジェクトのルートに assets という名前のディレクトリを作成します。
  2. assets ディレクトリ内に、musicsounds の 2 つのサブディレクトリを作成します。
  3. ダウンロードしたファイルをプロジェクトに移動して、曲のファイルを assets/music/looped-song.ogg ファイルに保存し、ピュー音を次のファイルに配置します。
  • assets/sounds/pew1.mp3
  • assets/sounds/pew2.mp3
  • assets/sounds/pew3.mp3

プロジェクトの構造は次のようになります。

「android」や「ios」などのフォルダ、「README.md」や「analysis_options.yaml」などのファイルがあるプロジェクトのツリービュー。その中には、`music` と `sounds` のサブディレクトリがある `assets` ディレクトリ、`main.dart` を含む `lib` ディレクトリと `audio_controller.dart` を含む `audio` サブディレクトリ、`pubspec.yaml` ファイルがあります。新しいディレクトリと、これまでにアクセスしたファイルを指す矢印。

ファイルが用意できたので、Flutter に通知する必要があります。

  1. pubspec.yaml ファイルを開き、ファイルの下部にある flutter: セクションを次のように置き換えます。

pubspec.yaml

...

flutter:
  uses-material-design: true

  assets:
    - assets/music/
    - assets/sounds/
  1. flutter_soloud パッケージと logging パッケージの依存関係を追加します。

pubspec.yaml

...

dependencies:
  flutter:
    sdk: flutter

  flutter_soloud: ^2.0.0
  logging: ^1.2.0

...
  1. プロジェクトを実行します。次のセクションで機能を追加しているため、まだ何も機能しません。

10f0f751c9c47038.png

/flutter_soloud/src/filters/filters.cpp:21:24: warning: implicit conversion loses integer precision: 'decltype(__x.base() - __y.base())' (aka 'long') to 'int' [-Wshorten-64-to-32];

これらは、基盤となる SoLoud C++ ライブラリから取得されます。機能には影響しないため、無視してもかまいません。

3. 初期化してシャットダウンする

音声を再生するには、flutter_soloud プラグインを使用します。このプラグインは、任天堂 SNES Classic で使用されているゲーム用の C++ オーディオ エンジンである SoLoud プロジェクトをベースにしています。

7ce23849b6d0d09a.png

SoLoud オーディオ エンジンを初期化する手順は次のとおりです。

  1. audio_controller.dart ファイルで、flutter_soloud パッケージをインポートし、非公開の _soloud フィールドをクラスに追加します。

lib/audio/audio_controller.dart

import 'dart:ui';

import 'package:flutter_soloud/flutter_soloud.dart';  // ← Add this...
import 'package:logging/logging.dart';

class AudioController {
  static final Logger _log = Logger('AudioController');

  SoLoud? _soloud;                                    // ← ... and this.

  Future<void> initialize() async {
    // TODO
  }

  ...

オーディオ コントローラは、このフィールドを通じて基盤となる SoLoud エンジンを管理し、すべての呼び出しをこのエンジンに転送します。

  1. initialize() メソッドに次のコードを入力します。

lib/audio/audio_controller.dart

...

  Future<void> initialize() async {
    _soloud = SoLoud.instance;
    await _soloud!.init();
  }

...

これにより、_soloud フィールドに値が入力され、初期化を待機します。次の点にご留意ください。

  • SoLoud はシングルトン instance フィールドを提供します。複数の SoLoud インスタンスをインスタンス化する方法はありません。これは C++ エンジンで許可されていないため、Dart プラグインでも許可されません。
  • プラグインの初期化は非同期で、init() メソッドが返されるまで完了しません。
  • わかりやすくするため、この例では try/catch ブロック内のエラーは検出していません。本番環境のコードでは、これを行い、エラーがあればユーザーに報告する必要があります。
  1. dispose() メソッドに次のコードを入力します。

lib/audio/audio_controller.dart

...

  void dispose() {
    _soloud?.deinit();
  }

...

アプリの終了時に SoLoud をシャットダウンすることをおすすめします。ただし、終了しなくても、すべて正常に動作するはずです。

  1. AudioController.initialize() メソッドはすでに main() 関数から呼び出されています。つまり、プロジェクトをホット リスタートすると、SoLoud はバックグラウンドで初期化されますが、実際にサウンドを再生するまでは効果がありません。

4. ワンショット サウンドを再生する

アセットを読み込んで再生する

起動時に SoLoud が初期化されたことを確認できたので、SoLoud に音声を再生するようリクエストできます。

SoLoud は、音源(音の説明に使用されるデータやメタデータ)と「サウンド インスタンス」(実際に再生された音)を区別します。オーディオ ソースの例として、メモリに読み込まれて再生可能な mp3 ファイルが AudioSource クラスのインスタンスで表されます。この音源を再生するたびに、SoLoud は「サウンド インスタンス」を作成しますSoundHandle 型で表されます。

これを読み込むと、AudioSource インスタンスを取得します。たとえば、アセットに mp3 ファイルがある場合は、それを読み込んで AudioSource を取得できます。その後、SoLoud にこのAudioSourceを再生するよう指示します。何度でも、同時にプレイできます。

オーディオ ソースが不要になった場合は、SoLoud.disposeSource() メソッドで破棄します。

アセットを読み込んで再生する手順は次のとおりです。

  1. AudioController クラスの playSound() メソッドに、次のコードを入力します。

lib/audio/audio_controller.dart

  ...

  Future<void> playSound(String assetKey) async {
    final source = await _soloud!.loadAsset(assetKey);
    await _soloud!.play(source);
  }

  ...
  1. ファイルを保存してホットリロードし、[音を鳴らす] を選択します。くだらないピューの音が聞こえるはずです。次の点にご留意ください。
  • 指定される assetKey 引数は assets/sounds/pew1.mp3 のようになります。これは、Image.asset() ウィジェットなど、アセットを読み込む他の Flutter API に指定する文字列と同じです。
  • SoLoud インスタンスには、Flutter プロジェクトのアセットから音声ファイルを非同期に読み込み、AudioSource クラスのインスタンスを返す loadAsset() メソッドが用意されています。ファイル システムからファイルを読み込むメソッド(loadFile() メソッド)と、URL からネットワーク経由で読み込むメソッド(loadUrl() メソッド)があります。
  • 新たに取得した AudioSource インスタンスは、SoLoud の play() メソッドに渡されます。このメソッドは、新たに再生されるサウンドを表す SoundHandle タイプのインスタンスを返します。次に、このハンドルを他の SoLoud メソッドに渡して、サウンドの一時停止、停止、音量の変更などを行うことができます。
  • play() は非同期メソッドですが、再生は基本的に瞬時に開始されます。flutter_soloud パッケージは、Dart の外部関数インターフェース(FFI)を使用して、C コードを直接同期的に呼び出します。ほとんどの Flutter プラグインに共通する、Dart コードとプラットフォーム コードの間の通常のやり取りはどこにもありません。一部のメソッドが非同期である唯一の理由は、プラグインの一部のコードが独自のアイソレート ツールで実行されることと、Dart のアイソレート ツール間の通信が非同期であることです。
  • _soloud! を使用して、_soloud フィールドが null でないことをアサートします。説明を簡潔にします。本番環境のコードでは、オーディオ コントローラが完全に初期化される前にデベロッパーが音を鳴らそうとした場合、適切に対処する必要があります。

例外に対処する

お気づきかもしれませんが、ここでも例外を無視していることに気付いたかもしれません。学習のために、この特定のメソッドを修正しましょう。(簡潔にするため、このセクションの後はこの Codelab では再び例外を無視します)。

  • この例外に対処するには、playSound() メソッドの 2 行を try/catch ブロックでラップし、SoLoudException のインスタンスのみをキャッチします。

lib/audio/audio_controller.dart

  ...

  Future<void> playSound(String assetKey) async {
    try {
      final source = await _soloud!.loadAsset(assetKey);
      await _soloud!.play(source);
    } on SoLoudException catch (e) {
      _log.severe("Cannot play sound '$assetKey'. Ignoring.", e);
    }
  }

  ...

SoLoud は、SoLoudNotInitializedException 例外や SoLoudTemporaryFolderFailedException 例外など、さまざまな例外をスローします。各メソッドの API ドキュメントには、スローされる可能性のある例外の種類が記載されています。

SoLoud には、そのすべての例外である SoLoudException 例外に対する親クラスも用意されているため、オーディオ エンジンの機能に関連するすべてのエラーをキャッチできます。これは、音声の再生が重要でない場合に特に役立ちます。たとえば、ピューピュー音のいずれかを読み込めなかったという理由でのみプレーヤーのゲーム セッションをクラッシュさせたくない場合です。

ご想像のとおり、存在しないアセットキーを指定した場合も、loadAsset() メソッドが FlutterError エラーをスローします。ゲームにバンドルされていないアセットを読み込もうとした場合、通常は対処する必要があります。そのため、エラーになります

さまざまなサウンドを再生する

お気づきかもしれませんが、再生しているのは pew1.mp3 ファイルのみですが、assets ディレクトリには他にも 2 つのバージョンのサウンドがあります。ゲームに同じサウンドのバージョンが複数あり、異なるバージョンをランダムに、または順番にプレイすると、より自然に聞こえる場合がよくあります。これにより、たとえば足音や銃声が不均一に聞こえるのを防ぎ、偽物に聞こえることを防ぎます。

  • オプションの演習として、ボタンがタップされるたびに異なるピュー音が再生されるようにコードを変更します。

イラスト:

5. 音楽ループを再生する

長時間使用するサウンドを管理する

音声によっては、長時間再生することを想定したものがあります。音楽がわかりやすい例ですが、多くのゲームも雰囲気を演出します。たとえば、回廊をうねる風、遠くに聞こえる修道士の声、何世紀も昔の金属のきしむ音、遠くの患者の咳など、空気感を演出するゲームも数多くあります。

再生時間を分単位で測定できる音源です。必要に応じて一時停止または停止できるように、追跡する必要があります。また、多くの場合、大きなファイルを基盤とし、大量のメモリを消費する可能性があります。そのため、AudioSource インスタンスが不要になったら破棄できるようにするためも、追跡する別の理由があります。

そのため、AudioController に新しいプライベート フィールドを導入します。現在再生中の曲のハンドル(存在する場合)です。次の行を追加します。

lib/audio/audio_controller.dart

...

class AudioController {
  static final Logger _log = Logger('AudioController');

  SoLoud? _soloud;

  SoundHandle? _musicHandle;    // ← Add this.

  ...

音楽再生を開始

基本的に、音楽を再生することは、ワンショット サウンドの再生と変わりません。まず、assets/music/looped-song.ogg ファイルを AudioSource クラスのインスタンスとして読み込んでから、SoLoud の play() メソッドを使用して再生する必要があります。

ただし、今回は、再生中の音声を操作するために play() メソッドが返すサウンド ハンドルを保持します。

  • 必要に応じて、AudioController.startMusic() メソッドを独自に実装します。詳細がわからなくても大丈夫です。重要なのは、[音楽を開始] を選択すると音楽が開始されるということです。

リファレンス実装は次のとおりです。

lib/audio/audio_controller.dart

...

  Future<void> startMusic() async {
    if (_musicHandle != null) {
      if (_soloud!.getIsValidVoiceHandle(_musicHandle!)) {
        _log.info('Music is already playing. Stopping first.');
        await _soloud!.stop(_musicHandle!);
      }
    }
    final musicSource = await _soloud!
        .loadAsset('assets/music/looped-song.ogg', mode: LoadMode.disk);
    _musicHandle = await _soloud!.play(musicSource);
  }

...

音楽ファイルをディスクモードで読み込みます(LoadMode.disk 列挙型)。これは単に、ファイルが必要に応じてチャンクでのみ読み込まれることを意味します。長時間の音声の場合、通常はディスクモードで読み込むことをおすすめします。短い効果音の場合、メモリ(デフォルトの LoadMode.memory 列挙型)に読み込んで解凍する方が理にかなっています。

ただし、いくつか問題があります。まず、音楽が大きすぎて音が大きすぎます。ほとんどのゲームではほとんどの場合、音楽がバックグラウンドになり、音声や効果音など、より有益な音声が中心となります。この問題は、play メソッドの音量パラメータを使用すると簡単に修正できます。たとえば、「_soloud!.play(musicSource, volume: 0.6)」と入力すると、曲を 60% の音量で再生することができます。後で _soloud!.setVolume(_musicHandle, 0.6) などを使用して、音量を設定することもできます。

2 つ目の問題は、曲が突然停止することです。これは、曲がループ再生されることを想定しており、ループの開始点が音声ファイルの先頭ではないためです。

88d2c57fffdfe996.png

これはゲーム音楽でよく使われる選択肢です。曲は自然なイントロから始まり、明確なループポイントなしで必要な時間だけ再生されます。ゲームは現在再生中の曲から遷移する必要がある場合、その曲がフェードするだけです。

ありがたいことに、SoLoud にはループする音声を再生する機能が用意されています。play() メソッドは、looping パラメータのブール値を取り、loopingStartAt パラメータとしてループの始点の値も受け取ります。変更後のコードは次のようになります。

lib/audio/audio_controller.dart

...

_musicHandle = await _soloud!.play(
  musicSource,
  volume: 0.6,
  looping: true,
  // ↓ The exact timestamp of the start of the loop.
  loopingStartAt: const Duration(seconds: 25, milliseconds: 43),
);

...

loopingStartAt パラメータを設定しない場合、デフォルトは Duration.zero(つまり、音声ファイルの先頭)になります。導入なしで完璧なループ再生になる音楽トラックであれば、これが理想的です。

  • 再生が終了したら、オーディオ ソースを適切に廃棄するために、各オーディオ ソースが提供する allInstancesFinished ストリームをリッスンします。ログ呼び出しを追加すると、startMusic() メソッドは次のようになります。

lib/audio/audio_controller.dart

...

  Future<void> startMusic() async {
    if (_musicHandle != null) {
      if (_soloud!.getIsValidVoiceHandle(_musicHandle!)) {
        _log.info('Music is already playing. Stopping first.');
        await _soloud!.stop(_musicHandle!);
      }
    }
    _log.info('Loading music');
    final musicSource = await _soloud!
        .loadAsset('assets/music/looped-song.ogg', mode: LoadMode.disk);
    musicSource.allInstancesFinished.first.then((_) {
      _soloud!.disposeSource(musicSource);
      _log.info('Music source disposed');
      _musicHandle = null;
    });

    _log.info('Playing music');
    _musicHandle = await _soloud!.play(
      musicSource,
      volume: 0.6,
      looping: true,
      loopingStartAt: const Duration(seconds: 25, milliseconds: 43),
    );
  }

...

フェード音

次の問題は、音楽に終わりがないということです。フェードを実装しましょう。

フェードを実装する方法としては、TickerTimer.periodic など、1 秒間に数回呼び出される関数を用意し、音楽の音量を少し減らす方法があります。これはうまくいきますが、大変な作業です。

幸いなことに、SoLoud にはこれを行う便利なファイア アンド フォーゲット方式が提供されています。ここでは、音楽を 5 秒間フェードし、CPU リソースを不必要に消費しないようにサウンド インスタンスを停止する方法を示します。fadeOutMusic() メソッドを次のコードに置き換えます。

lib/audio/audio_controller.dart

...

  void fadeOutMusic() {
    if (_musicHandle == null) {
      _log.info('Nothing to fade out');
      return;
    }
    const length = Duration(seconds: 5);
    _soloud!.fadeVolume(_musicHandle!, 0, length);
    _soloud!.scheduleStop(_musicHandle!, length);
  }

...

6. エフェクトを適用する

適切なオーディオ エンジンを自由に利用できる大きなメリットの 1 つは、リバーブ、イコライザー、またはローパス フィルタで一部のサウンドをルーティングするなどのオーディオ処理を行えることです。

ゲームでは、場所の聴覚識別に利用できます。たとえば、森とコンクリートのバンカーでは拍手の音が異なります。森は音の消散と吸収を助けますが、バンカーのむき出しの壁は音波を反射し、リバーブにつながります。同様に、壁越しで聞こえる人の声は、違ったように聞こえます。こうした高周波は、固体の媒体を通過するときに簡単に減衰するため、ローパス フィルタ効果が得られます。

部屋で会話している 2 人の人物のイラスト。音波は、一方の人からもう一方の人に直接伝わるだけでなく、壁や天井で跳ね返ります。

SoLoud には、音声に適用できるさまざまなオーディオ エフェクトが用意されています。

  • プレーヤーが大聖堂や洞窟のような広い部屋にいるように見せるには、FilterType.freeverbFilter 列挙型を使用します。

lib/audio/audio_controller.dart

...

  void applyFilter() {
    _soloud!.addGlobalFilter(FilterType.freeverbFilter);
    _soloud!.setFilterParameter(FilterType.freeverbFilter, 0, 0.2);
    _soloud!.setFilterParameter(FilterType.freeverbFilter, 2, 0.9);
  }

  void removeFilter() {
    _soloud!.removeGlobalFilter(FilterType.freeverbFilter);
  }

...

このように、フィルタを使用すると、より低いレベルの領域を掘り下げることができます。フィルタ パラメータの設定は、パラメータのインデックスを使用して行います。たとえば、フリーバーブの Wet パラメータにはインデックス 0 があり、Room Size パラメータにはインデックス 2 があります。

上記のコードで、次のことを行います。

  • フリー動詞フィルタは、1 つのサウンドだけでなく、グローバルに、またはオーディオ ミックス全体で有効にできます。
  • Wet パラメータを 0.2 に設定します。これにより、オーディオが 80% オリジナル、20% リバーブエフェクトの出力となります。このパラメータを 1.0 に設定すると、部屋の遠くの壁から聞こえてくる音波だけが聞こえ、元の音声は聞こえないようになります。
  • [Room Size] パラメータを 0.9 に設定します。このパラメータは、好みに合わせて微調整することも、動的に変更することもできます。1.0 は大きな洞窟ですが、0.0 は浴室です。
  • 必要であれば、コードを変更して以下のフィルタのいずれか、または次のフィルタの組み合わせを適用します。
  • FilterType.biquadResonantFilter(ローパス フィルタとして使用可能)
  • FilterType.eqFilter
  • FilterType.echoFilter
  • FilterType.lofiFilter
  • FilterType.flangerFilter
  • FilterType.bassboostFilter
  • FilterType.waveShaperFilter
  • FilterType.robotizeFilter
  • FilterType.freeverbFilter

7. 完了

音声の再生、音楽のループ、エフェクトの適用を行うオーディオ コントローラを実装しました。

その他の情報

  • 起動時のサウンドのプリロード、曲の連続再生、経時的な徐々にフィルタの適用などの機能を使用して、オーディオ コントローラの機能をさらに強化してみましょう。
  • flutter_soloudパッケージ ドキュメントを読む。
  • 基になる C++ ライブラリのホームページを読む。
  • C++ ライブラリとのインターフェースに使用されるテクノロジーである Dart FFI の詳細を確認する。
  • ゲームのオーディオ プログラミングに関する Guy Somberg の講演を見て、アイデアを得ましょう。(さらに長いメッセージもあります)。Guy が「ミドルウェア」と呼んでいる場合、彼は SoLoud や FMOD などのライブラリのことを指します。コードの残りの部分は、ゲームごとに固有になる傾向があります。
  • ゲームをビルドしてリリースします。

ヘッドフォンのイラスト