1. 始める前に
ゲームは映像と音声を伴うエクスペリエンスです。Flutter は、美しいビジュアルと堅実な UI を構築するための優れたツールなので、ビジュアルの側面を深く理解できます。残りの要素は音声です。この Codelab では、flutter_soloud
プラグインを使用して、低レイテンシのサウンドと音楽をプロジェクトに導入する方法を学びます。すぐに興味深い部分にジャンプできるように、基本的なスキャフォールドから始めます。
ここで学んだ内容は、ゲームだけでなくアプリにも音声を追加するために使用できます。ただし、ほとんどのゲームではサウンドと音楽が必要ですが、ほとんどのアプリでは必要ないため、この Codelab ではゲームに焦点を当てます。
前提条件
- Flutter に関する基本的な知識。
- Flutter アプリを実行してデバッグする方法に関する知識
学習内容
- ワンショット音声を再生する方法。
- ギャップレス音楽ループを再生、カスタマイズする方法。
- 音声をフェードインまたはフェードアウトする方法。
- 音に環境効果を適用する方法
- 例外に対処する方法
- これらすべての機能を 1 つのオーディオ コントローラにカプセル化する方法
必要なもの
- Flutter SDK
- 任意のコードエディタ
2. 設定
- 次のファイルをダウンロードします。接続速度が遅くても心配はいりません。実際のファイルは後で必要になるため、作業中にそれらのファイルをダウンロードできます。
- 任意の名前で Flutter プロジェクトを作成します。
- プロジェクトに
lib/audio/audio_controller.dart
ファイルを作成します。 - ファイルに次のコードを入力します。
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 で、これらすべてを実装します。
- 次に、
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();
}
},
),
],
),
],
),
),
);
}
}
- 音声ファイルがダウンロードされたら、プロジェクトのルートに
assets
という名前のディレクトリを作成します。 assets
ディレクトリに、music
とsounds
という 2 つのサブディレクトリを作成します。- ダウンロードしたファイルをプロジェクトに移動して、曲ファイルが
assets/music/looped-song.ogg
ファイルに、ピュー音が次のファイルに配置されるようにします。
assets/sounds/pew1.mp3
assets/sounds/pew2.mp3
assets/sounds/pew3.mp3
プロジェクトの構造は次のようになります。
ファイルが用意できたので、Flutter に通知する必要があります。
pubspec.yaml
ファイルを開き、ファイルの下部にあるflutter:
セクションを次のように置き換えます。
pubspec.yaml
...
flutter:
uses-material-design: true
assets:
- assets/music/
- assets/sounds/
flutter_soloud
パッケージとlogging
パッケージへの依存関係を追加します。
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
flutter_soloud: ^2.0.0
logging: ^1.2.0
...
- プロジェクトを実行します。次のセクションで機能を追加しているため、まだ何も機能しません。
/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 プロジェクトに基づいています。
SoLoud オーディオ エンジンを初期化する手順は次のとおりです。
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 エンジンを管理し、すべての呼び出しを転送します。
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
ブロックでエラーをキャッチしていません。本番環境のコードでは、それを行い、エラーがあればユーザーに報告する必要があります。
dispose()
メソッドに次のコードを入力します。
lib/audio/audio_controller.dart
...
void dispose() {
_soloud?.deinit();
}
...
アプリの終了時に SoLoud をシャットダウンすることをおすすめしますが、シャットダウンしなくても問題なく動作します。
AudioController.initialize()
メソッドはすでにmain()
関数から呼び出されています。つまり、プロジェクトをホットリスタートすると、SoLoud はバックグラウンドで初期化されますが、実際にサウンドを再生するまでは効果がありません。
4. ワンショット音を再生する
アセットを読み込んで再生する
起動時に SoLoud が初期化されたことを確認できたので、SoLoud に音声を再生するようリクエストできます。
SoLoud は、音源(音の説明に使用されるデータやメタデータ)と「サウンド インスタンス」(実際に再生された音)を区別します。オーディオ ソースの例として、メモリに読み込まれて再生可能な mp3 ファイルが AudioSource
クラスのインスタンスで表されます。この音源を再生するたびに、SoLoud は「サウンド インスタンス」を作成しますSoundHandle
型で表されます。
AudioSource
インスタンスは読み込むことで取得できます。たとえば、アセットに mp3 ファイルがある場合は、そのファイルを読み込んで AudioSource
を取得できます。次に、この AudioSource
を再生するよう SoLoud に指示します。複数回、または同時にプレイすることもできます。
音声ソースの使用が完了したら、SoLoud.disposeSource()
メソッドで破棄します。
アセットを読み込んで再生する手順は次のとおりです。
AudioController
クラスのplaySound()
メソッドに次のコードを入力します。
lib/audio/audio_controller.dart
...
Future<void> playSound(String assetKey) async {
final source = await _soloud!.loadAsset(assetKey);
await _soloud!.play(source);
}
...
- ファイルを保存してホットリロードし、[音声を再生] を選択します。シューという音が聞こえます。次の点にご留意ください。
- 指定される
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 つ目の問題は、曲が突然停止することです。これは、ループ再生が想定されている曲で、ループの開始点が音声ファイルの開始点ではないためです。
これはゲーム音楽でよく使われる選択肢です。曲は自然なイントロで始まり、明確なループポイントなしで必要な時間だけ再生されます。ゲームは現在再生中の曲から遷移する必要がある場合、その曲がフェードするだけです。
幸い、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),
);
}
...
フェード音
次の問題は、音楽が終わらないことです。フェードを実装しましょう。
フェードを実装する方法としては、Ticker
や Timer.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 つは、リバーブ、イコライザー、ローパス フィルタなど、オーディオ処理を行えることです。
ゲームでは、場所の音響的な差別化に使用できます。たとえば、森とコンクリートのバンカーでは拍手の音が異なります。森は音の消散と吸収を助けますが、バンカーのむき出しの壁は音波を反射し、リバーブにつながります。同様に、壁越しに聞こえる人の声は、直接聞こえる声とは異なります。これらの音の高周波数は、固体媒体を伝わるにつれて減衰しやすく、ローパス フィルタ効果が生じます。
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 などのライブラリのことを指します。コードの残りの部分は、ゲームごとに固有になる傾向があります。
- ゲームをビルドしてリリースします。