1. 准备工作
游戏是一种音像体验。Flutter 非常适合用于构建美观的视觉效果和稳健的界面,因此在视觉方面,它可以为您提供很大帮助。所剩的要素是音频。在此 Codelab 中,您将学习如何使用 flutter_soloud
插件向项目引入低延迟音效和音乐。您将从一个基本的基架开始,以便能够直接跳到感兴趣的部分。
当然,您可以运用在这里学到的知识,将音频添加到您的应用,而不仅仅是游戏。不过,虽然几乎所有游戏都需要声音和音乐,但大多数应用不需要,因此此 Codelab 将重点介绍游戏。
前提条件
- 基本熟悉 Flutter。
- 了解如何运行和调试 Flutter 应用。
学习内容
- 如何播放一次性音效。
- 如何播放和自定义无间断音乐循环。
- 如何淡入和淡出声音。
- 如何对声音应用环境特效。
- 如何处理异常。
- 如何将所有这些功能封装到单个音频控制器中。
所需条件
- 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
。 - 将下载的文件移至您的项目,使歌曲文件位于
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
插件。此插件基于 SoLoud 项目,SoLoud 项目是任天堂 SNES Classic 旗下游戏使用的 C++ 音频引擎。
如需初始化 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 是一种良好做法,但即使您忘记这样做,一切应该也能正常运行。
- 请注意,已从
main()
函数调用了AudioController.initialize()
方法。这意味着,热重启项目会在后台初始化 SoLoud,但在您实际播放一些声音之前,它不会对您有任何帮助。
4. 播放一次性提示音
加载并播放作品
现在,您知道 SoLoud 会在启动时初始化,接下来您可以要求它播放声音了。
SoLoud 可以区分音频源(用于描述声音的数据和元数据)和其“声音实例”(实际播放的声音)的“声音实例”。例如,音频源可以是加载到内存中、准备播放并由 AudioSource
类的实例表示的 mp3 文件。每次您播放此音频来源时,SoLoud 都会创建一个“声音实例”由 SoundHandle
类型表示。
您将通过加载 AudioSource
实例来获取该实例。例如,如果您的素材资源中有一个 mp3 文件,您可以加载该文件以获取 AudioSource
。然后,您可以指示 SoLoud 播放此 AudioSource
。您可以多次播放该歌曲,甚至可以同时播放。
使用完音频源后,您可以使用 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);
}
...
- 保存文件、热重载,然后选择播放音效。您应该会听到一个傻乎乎的“pew”声。请注意以下几点:
- 提供的
assetKey
参数类似于assets/sounds/pew1.mp3
,也就是您将提供给任何其他资源加载 Flutter API(例如Image.asset()
widget)的字符串。 - SoLoud 实例提供了一个
loadAsset()
方法,用于异步从 Flutter 项目的资源加载音频文件,并返回AudioSource
类的实例。有等效的方法可用于从文件系统加载文件(loadFile()
方法),以及通过网络从网址加载文件(loadUrl()
方法)。 - 然后,新获取的
AudioSource
实例会传递给 SoLoud 的play()
方法。此方法会返回一个SoundHandle
类型的实例,表示新播放的声音。反过来,此句柄可传递给其他 SoLoud 方法,以执行暂停、停止或修改声音音量等操作。 - 虽然
play()
是一种异步方法,但播放基本上会立即开始。flutter_soloud
软件包使用 Dart 的外部函数接口 (FFI) 直接同步调用 C 代码。在大多数 Flutter 插件中,Dart 代码和平台代码之间通常会进行来回消息传递,但在该插件中却找不到这种情况。某些方法是异步的唯一原因是,插件中的某些代码在自己的 isolate 中运行,而 Dart isolate 之间的通信是异步的。 - 您只需使用
_soloud!
断言_soloud
字段不为 null 即可。再次强调,当开发者在音频控制器有机会完全初始化之前尝试播放音频时,正式版代码应妥善处理此类情况。
处理异常
您可能已经注意到,您再次忽略了可能出现的异常。为了学习目的,我们来修正一下这个特定方法。(为简单起见,在本部分之后,此 Codelab 将恢复为忽略异常。)
- 在本例中,如需处理异常,请将
playSound()
方法的两行代码封装在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 目录中还有另外两个版本的该音效。如果游戏包含多个版本相同的音效,并以随机方式或循环播放不同版本,这往往听起来更自然。例如,这可以防止步伐和枪声听起来过于统一,因此是假的。
- 作为一项可选练习,您可以修改代码,以便每次点按按钮时播放不同的 pew 音效。
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)
的指令设置音量。
第二个问题是歌曲突然停止播放。这是因为这首歌曲应循环播放,并且循环的起点不是音频文件的开头。
这是游戏音乐的热门选择,因为这意味着歌曲从自然的片头开始,然后根据需要持续播放,没有明显的循环点。当游戏需要从当前播放的歌曲转换时,只需让歌曲淡出即可。
幸运的是,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. 应用特效
拥有合适的音频引擎的一个巨大优势是,您可以进行音频处理,例如将某些声音通过混响、均衡器或低通滤波进行路由。
在游戏中,这可用于对不同位置进行听觉区分。例如,在森林和混凝土地堡中,拍手的声音是不同的。森林有助于消散和吸收声音,而掩体内的光秃秃的墙壁会将声波反射回来,导致回响。同样,通过墙壁听到的人声也会有所不同。这些声音的较高频率在通过固体媒介时更容易衰减,从而形成低通滤波效果。
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
公开了全局过滤器。如果您想对单个来源应用过滤条件,请使用具有相同作用的对应 AudioSource.filters
。
使用前面的代码,您可以执行以下操作:
- 全局启用 freeverb 过滤条件。
- 将 Wet 参数设置为
0.2
,这意味着生成的音频将由 80% 的原始音频和 20% 的混响效果输出组成。如果您将此参数设置为1.0
,则就好像只听到从房间远处墙壁反射回来的声波,而听不到任何原始音频。 - 将客房大小参数设置为
0.9
。您可以根据自己的喜好调整此参数,甚至可以动态地对其进行更改。1.0
是一个巨大的洞穴,而0.0
是一个洗手间。
- 如果您愿意,请更改代码并应用以下某个过滤条件或以下过滤条件组合:
biquadFilter
(可用作低通滤波器)pitchShiftFilter
equalizerFilter
echoFilter
lofiFilter
flangerFilter
bassboostFilter
waveShaperFilter
robotizeFilter
7. 恭喜
您实现了一个音频控制器,用于播放声音、循环音乐和应用效果。
了解详情
- 尝试通过启动时预加载音效、顺序播放歌曲或随着时间的推移逐渐应用滤镜等功能,进一步扩展音频控制器的功能。
- 阅读
flutter_soloud
的软件包文档。 - 阅读底层 C++ 库的首页。
- 详细了解 Dart FFI,一种用于与 C++ 库进行交互的技术。
- 观看 Guy Somberg 关于游戏音频编排的演讲,寻找灵感。(还有一个更长的版本。)Guy 提到的“中间件”是指 SoLoud 和 FMOD 等库。其余代码往往特定于每款游戏。
- 构建游戏并发布。