为您的 Flutter 游戏添加声音和音乐

1. 准备工作

游戏是一种音像体验。Flutter 非常适合用于构建美观的视觉效果和稳健的界面,因此在视觉方面,它可以为您提供很大帮助。所剩的要素是音频。在此 Codelab 中,您将学习如何使用 flutter_soloud 插件向项目引入低延迟音效和音乐。您将从一个基本的基架开始,以便能够直接跳到感兴趣的部分。

手绘的耳机插图。

当然,您可以运用在这里学到的知识,将音频添加到您的应用,而不仅仅是游戏。不过,虽然几乎所有游戏都需要声音和音乐,但大多数应用不需要,因此此 Codelab 将重点介绍游戏。

前提条件

  • 基本熟悉 Flutter。
  • 了解如何运行和调试 Flutter 应用。

学习内容

  • 如何播放一次性音效。
  • 如何播放和自定义无间断音乐循环。
  • 如何淡入和淡出声音。
  • 如何对声音应用环境特效。
  • 如何处理异常。
  • 如何将所有这些功能封装到单个音频控制器中。

所需条件

  • 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
  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 插件。此插件基于 SoLoud 项目,SoLoud 项目是任天堂 SNES Classic 旗下游戏使用的 C++ 音频引擎。

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. 请注意,已从 main() 函数调用了 AudioController.initialize() 方法。这意味着,热重启项目会在后台初始化 SoLoud,但在您实际播放一些声音之前,它不会对您有任何帮助。

4. 播放一次性提示音

加载并播放作品

现在,您知道 SoLoud 会在启动时初始化,接下来您可以要求它播放声音了。

SoLoud 可以区分音频源(用于描述声音的数据和元数据)和其“声音实例”(实际播放的声音)的“声音实例”。例如,音频源可以是加载到内存中、准备播放并由 AudioSource 类的实例表示的 mp3 文件。每次您播放此音频来源时,SoLoud 都会创建一个“声音实例”由 SoundHandle 类型表示。

您将通过加载 AudioSource 实例来获取该实例。例如,如果您的素材资源中有一个 mp3 文件,您可以加载该文件以获取 AudioSource。然后,您可以指示 SoLoud 播放此 AudioSource。您可以多次播放该歌曲,甚至可以同时播放。

使用完音频源后,您可以使用 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. 保存文件、热重载,然后选择播放音效。您应该会听到一个傻乎乎的“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 会抛出各种异常,例如 SoLoudNotInitializedExceptionSoLoudTemporaryFolderFailedException 异常。每个方法的 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) 的指令设置音量。

第二个问题是歌曲突然停止播放。这是因为这首歌曲应循环播放,并且循环的起点不是音频文件的开头。

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)一秒调用多次,然后将音乐的音量小幅减少。这样做行得通,但工作量非常大。

幸运的是,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 等库。其余代码往往特定于每款游戏。
  • 构建游戏并发布。

耳机的插图