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 プラグインを使用します。このプラグインは、Nintendo 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 を取得できます。次に、この AudioSource を再生するよう SoLoud に指示します。複数回、または同時にプレイすることもできます。

音声ソースの使用が完了したら、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 メソッドの volume パラメータを使用して簡単に修正できます。たとえば、「_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 には、音声に適用できるさまざまなオーディオ エフェクトが用意されています。

  • プレイヤーが大聖堂や洞窟などの大きな部屋にいるように聞こえるようにするには、SoLoud.filters フィールドを使用します。

lib/audio/audio_controller.dart

...

  void applyFilter() {
    _soloud!.filters.freeverbFilter.activate();
    _soloud!.filters.freeverbFilter.wet.value = 0.2;
    _soloud!.filters.freeverbFilter.roomSize.value = 0.9;
  }

  void removeFilter() {
    _soloud!.filters.freeverbFilter.deactivate();
  }

...

SoLoud.filters フィールドを使用すると、すべてのフィルタタイプとそのパラメータにアクセスできます。どのパラメータにも、段階的なフェードやオシレーションなどの機能が組み込まれています。

注: _soloud!.filters はグローバル フィルタを公開します。1 つのソースにフィルタを適用する場合は、同じ動作をする対応する AudioSource.filters を使用してください。

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

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

7. 完了

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

その他の情報

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

ヘッドフォンのイラスト