关于此 Codelab
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),
),
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
软件包的依赖项。
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
插件。此插件基于 SoLoud 项目,这是一个游戏专用 C++ 音频引擎,Nintendo SNES Classic 等游戏都使用了该引擎。
如需初始化 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 是一种良好做法,但即使您忘记这样做,一切也应该会正常运行。
- 请注意,系统已从
main()
函数调用AudioController.initialize()
方法。这意味着,热重启项目会在后台初始化 SoLoud,但在您实际播放一些声音之前,它不会对您有任何帮助。
4. 播放一次性提示音
加载资产并播放
现在,您已经知道 SoLoud 会在启动时进行初始化,接下来可以让它播放声音了。
SoLoud 会区分音频源(用于描述声音的数据和元数据)及其“声音实例”(即实际播放的声音)。音频源示例可以是加载到内存中、准备好播放且由 AudioSource
类的实例表示的 mp3 文件。每次播放此音源时,SoLoud 都会创建一个“声音实例”,该实例由 SoundHandle
类型表示。
您可以通过加载 AudioSource
来获取 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
字段不为 null,并且包含_soloud!
。再次强调,这是为了简洁起见。当开发者在音频控制器有机会完全初始化之前尝试播放音频时,正式版代码应妥善处理此类情况。
处理异常
您可能已经注意到,您又一次忽略了可能的异常情况。为了学习目的,我们需要修正此特定方法。(为简洁起见,此 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
异常,以便您捕获与音频引擎功能相关的所有错误。如果播放音频不是必不可少的,这种做法尤为有用。例如,您不希望仅仅因为某个“pew-pew”音效无法加载就导致玩家的游戏会话崩溃。
正如您所料,如果您提供的素材资源键不存在,loadAsset()
方法也可能会抛出 FlutterError
错误。尝试加载未与游戏捆绑的资源通常是您应该解决的问题,因此这是一个错误。
播放不同的音效
您可能已经注意到,您只播放了 pew1.mp3
文件,但 assets 目录中还有另外两个版本的该音效。游戏中如果有同一音效的多个版本,并以随机或轮替的方式播放不同的版本,听起来通常会更自然。例如,这可以防止脚步声和枪声听起来过于一致,从而显得虚假。
- 作为一项可选练习,请修改代码,以便每次点按按钮时播放不同的教堂座位声音。
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,
);
}
...
请注意,您是在磁盘模式(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