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

向 Flutter 游戏添加声音和音乐

关于此 Codelab

subject上次更新时间:6月 6, 2025
account_circleFilip Hracek 编写

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),
     
),
     
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 软件包的依赖项。
flutter pub add flutter_soloud logging

您的 pubspec.yaml 文件现在应对 flutter_soloudlogging 软件包具有额外的依赖项。

pubspec.yaml

...

dependencies
:
  flutter
:
    sdk
: flutter

  flutter_soloud
: ^3.1.10
  logging
: ^1.3.0

...
  1. 运行项目。目前还没有任何操作可用,因为您将在后续部分中添加功能。

10f0f751c9c47038.png

3. 初始化和关闭

如需播放音频,请使用 flutter_soloud 插件。此插件基于 SoLoud 项目,这是一个游戏专用 C++ 音频引擎,Nintendo SNES Classic 等游戏都使用了该引擎。

7ce23849b6d0d09a.png

如需初始化 SoLoud 音频引擎,请按以下步骤操作:

  1. 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 引擎,并会将所有调用转发给它。

  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 来获取 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 字段不为 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 会抛出各种异常,例如 SoLoudNotInitializedExceptionSoLoudTemporaryFolderFailedException 异常。每个方法的 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) 等设置音量。

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

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 等库。其余代码通常因游戏而异。
  • 构建游戏并发布。

一副耳机的插图