为您的 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 目录中,创建两个子目录,一个名为 music,另一个名为 sounds
  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

/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

如需初始化 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. 保存文件,热重载,然后选择响铃。你应该会听到一声傻傻的长笛声。请注意以下几点:
  • 提供的 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 代码。我们找不到 Dart 代码与平台代码之间的常规来回消息,这也是大多数 Flutter 插件的典型特征。某些方法之所以是异步的,唯一的原因是插件的某些代码是在其自己的隔离环境中运行,并且 Dart 隔离区之间的通信是异步的。
  • 只需通过 _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 文件,但资产目录中有两个其他版本的声音。如果游戏包含多个版本相同的音效,并以随机方式或循环播放不同版本,这往往听起来更自然。例如,这可以防止步伐和枪声听起来过于统一,因此是假的。

  • 作为可选练习,修改代码,使用户每次点按按钮时都能播放不同的提示声音。

图示:

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

这是游戏音乐的热门选择,因为这意味着歌曲从自然的片头开始,然后根据需要持续播放,没有明显的循环点。当游戏需要从当前正在播放的歌曲过渡出来时,会直接淡化播放的歌曲。

幸运的是,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 提供了几种不同的音效,您可以将其应用于音频。

  • 如需让玩家听起来像在大房间(例如大教堂或洞穴)中,请使用 FilterType.freeverbFilter 枚举:

lib/audio/audio_controller.dart

...

  void applyFilter() {
    _soloud!.addGlobalFilter(FilterType.freeverbFilter);
    _soloud!.setFilterParameter(FilterType.freeverbFilter, 0, 0.2);
    _soloud!.setFilterParameter(FilterType.freeverbFilter, 2, 0.9);
  }

  void removeFilter() {
    _soloud!.removeGlobalFilter(FilterType.freeverbFilter);
  }

...

如您所见,借助过滤器,您可以深入研究更低级别的区域。过滤器参数的设置是通过参数的索引实现的。例如,freeverb 的 Wet 参数的索引为 0,Room Size 参数的索引为 2

使用前面的代码,您可以执行以下操作:

  • 为全局或整个混音(而不仅仅是单一声音)启用自由动词过滤器。
  • Wet 参数设置为 0.2,这意味着最终的音频将获得 80% 的原始音频和 20% 的混响效果输出。如果将此参数设置为 1.0,就好像只会听到从远处房间墙壁返回到您的声波,而不听到任何原始音频。
  • 房间大小参数设置为 0.9。您可以根据自己的喜好调整此参数,甚至可以动态地对其进行更改。1.0是一个巨大的洞穴,而0.0是一个洗手间。
  • 如果您愿意,请更改代码并应用以下某个过滤条件或以下过滤条件组合:
  • FilterType.biquadResonantFilter(可用作低通滤波器)
  • FilterType.eqFilter
  • FilterType.echoFilter
  • FilterType.lofiFilter
  • FilterType.flangerFilter
  • FilterType.bassboostFilter
  • FilterType.waveShaperFilter
  • FilterType.robotizeFilter
  • FilterType.freeverbFilter

7. 恭喜

您实现了一个音频控制器,用于播放声音、循环音乐和应用效果。

了解详情

  • 尝试通过如下功能进一步优化音频控制器:启动时预加载声音、按顺序播放歌曲,或随着时间的推移逐步应用滤镜。
  • 阅读 flutter_soloud软件包文档
  • 阅读底层 C++ 库的首页
  • 详细了解 Dart FFI,一种用于与 C++ 库进行交互的技术。
  • 观看 Guy Somberg 关于游戏音频编排的演讲,寻找灵感。(还有一个更长的。)当 Guy 提到“中间件”时,他指的是 SoLoud 和 FMOD 等库。其余代码往往特定于每款游戏。
  • 构建并发布游戏。

耳机的插图