この Codelab について
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),
),
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
パッケージへの依存関係を追加します。
flutter pub add flutter_soloud logging
これで、pubspec.yaml
ファイルに flutter_soloud
パッケージと logging
パッケージに対する追加の依存関係が設定されます。
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
flutter_soloud: ^3.1.10
logging: ^1.3.0
...
- プロジェクトを実行します。まだ機能しません。この機能は次のセクションで追加します。
3. 初期化とシャットダウン
音声を再生するには、flutter_soloud
プラグインを使用します。このプラグインは、Nintendo SNES Classic などで使用されているゲーム用の C++ オーディオ エンジンである SoLoud プロジェクトに基づいています。
SoLoud オーディオ エンジンを初期化する手順は次のとおりです。
audio_controller.dart
ファイルで、flutter_soloud
パッケージをインポートし、プライベート_soloud
フィールドをクラスに追加します。
lib/audio/audio_controller.dart
import 'dart:async';
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 は、音声ソース(音声の記述に使用されるデータとメタデータ)と、実際に再生される音声の「音声インスタンス」を区別します。音声ソースの例としては、メモリに読み込まれて再生可能な状態の 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 でないことを確認します。これは簡潔にするためです。本番環境のコードでは、オーディオ コントローラが完全に初期化される前にデベロッパーが音声を再生しようとした場合に、適切に対処する必要があります。
例外に対処する
例外が発生する可能性を無視していることに、気づいたかもしれません。学習目的で、この特定のメソッドを修正する時が来ました。(簡潔にするために、このセクションの後で例外の無視に戻ります)。
- この場合の例外に対処するには、
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
例外も用意されているため、オーディオ エンジンの機能に関連するすべてのエラーをキャッチできます。これは、音声の再生が重要でない場合に特に役立ちます。たとえば、銃撃音の 1 つが読み込まれなかったという理由だけで、プレーヤーのゲーム セッションをクラッシュさせたくない場合などです。
当然のことながら、存在しないアセットキーを指定すると、loadAsset()
メソッドで FlutterError
エラーがスローされることもあります。ゲームにバンドルされていないアセットを読み込もうとすることは、通常は対処すべき問題であるため、エラーとなります。
さまざまな音を再生する
pew1.mp3
ファイルのみが再生されていることに気付いたかもしれません。しかし、assets ディレクトリには、この音声の他にも 2 つのバージョンがあります。ゲームで同じ音声の複数のバージョンを用意し、ランダムに再生したり、ローテーションで再生したりすると、より自然な音になります。これにより、足音や銃声が均一になりすぎ、偽物のように聞こえるのを防ぐことができます。
- オプションの演習として、ボタンをタップするたびに異なるピュー音を鳴らすようにコードを変更します。
5. 音楽ループを再生する
長時間再生される音声を管理する
一部の音声は長時間再生することを目的としています。音楽が最もわかりやすい例ですが、多くのゲームでは、廊下を吹き抜ける風、遠くから聞こえる僧侶の唱歌、何百年も前の金属のきしむ音、遠くから聞こえる患者の咳など、環境音も再生されます。
再生時間が分単位で測定できるオーディオ ソースです。必要に応じて一時停止または停止できるように、これらのタスクを記録しておく必要があります。また、多くの場合、大きなファイルが基盤となっているため、メモリを大量に消費する可能性があります。追跡するもう 1 つの理由は、不要になった 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,
);
}
...
音楽ファイルをディスクモード(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
など、毎秒数回呼び出される関数を用意し、音楽の音量を少しずつ下げる方法です。これは機能しますが、手間がかかります。
幸い、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
を使用します。
上記のコードでは、次の処理が行われます。
- freeverb フィルタをグローバルに有効にします。
- 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 の講演を見て、アイデアを得ましょう。(長い動画もあります)。ガイが「ミドルウェア」と言っているのは、SoLoud や FMOD などのライブラリを指しています。残りのコードはゲームごとに固有のものです。
- ゲームをビルドしてリリースします。