在 Flutter 遊戲中加入聲音和音樂

1. 事前準備

遊戲是影音體驗,Flutter 是打造精美視覺效果和穩固 UI 的絕佳工具,因此在視覺方面可發揮極大作用。其餘食材缺少音訊。在本程式碼研究室中,您將瞭解如何使用 flutter_soloud 外掛程式,在專案中加入低延遲的音效和音樂。您可以先從基本架構開始,直接跳到有趣的部分。

耳機的手繪插圖。

當然,您也可以將這裡學到的知識應用到應用程式 (不只限於遊戲) 中,新增音訊。雖然幾乎所有遊戲都需要音效和音樂,但大多數應用程式並不需要,因此本程式碼研究室會著重於遊戲。

必要條件

  • 對 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
  }
}

如您所見,這只是未來功能的基礎,我們會在本程式碼研究室中完整實作。

  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 檔案中,而 pew 音效則位於下列檔案中:
  • 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 專案為基礎,這是一款遊戲專用的 C++ 音訊引擎,Nintendo SNES Classic 等產品也使用這款引擎。

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 是良好的做法,但即使您未關閉 SoLoud,一切也應能正常運作。

  1. 請注意,系統已經從 main() 函式呼叫 AudioController.initialize() 方法。也就是說,啟動熱重新啟動專案會在背景中初始化 SoLoud,但在實際播放音訊前,並不會有任何效果。

4. 播放單樣本音效

載入並播放素材資源

您現在已瞭解 SoLoud 會在啟動時初始化,因此可以要求它播放音效。

SoLoud 會區分音訊來源 (用來描述音效的資料與中繼資料) 和音訊來源 (即實際播放的聲音的資料)。音訊來源的例子包括載入記憶體的 mp3 檔案,可供播放,並以 AudioSource 類別的執行個體表示。每次播放這個音訊來源時,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. 儲存檔案並熱重新整理,然後選取「Play sound」。你應該會聽到愚蠢的「pew」聲。注意事項:
  • 提供的 assetKey 引數類似 assets/sounds/pew1.mp3,也就是您提供給任何其他資產載入 Flutter API 的字串,例如 Image.asset() 小工具。
  • SoLoud 例項提供 loadAsset() 方法,可從 Flutter 專案的資產以非同步方式載入音訊檔案,並傳回 AudioSource 類別的例項。有很多方法可以從檔案系統 (loadFile() 方法) 載入檔案,並且從網路 (loadUrl() 方法) 透過網路載入檔案。
  • 接著,新取得的 AudioSource 例項會傳遞至 SoLoud 的 play() 方法。這個方法會傳回代表新播放音效的 SoundHandle 類型例項。這個控點可對其他 SoLoud 方法傳遞至其他 SoLoud 方法,以便暫停、停止或修改聲音的音量。
  • 雖然 play() 是非同步方法,但播放作業基本上會立即開始。flutter_soloud 套件會使用 Dart 的外函式介面 (FFI) 直接且同步呼叫 C 程式碼。在大多數 Flutter 外掛程式中,Dart 程式碼和平台程式碼之間的訊息傳遞作業通常是雙向進行,但這項作業在 Dart 中並未實現。某些方法具有非同步特性,唯一的原因是外掛程式的部分程式碼自行獨立執行,而 Dart 隔離間的通訊也是非同步的。
  • 您只需使用 _soloud! 斷言 _soloud 欄位並非空值。這也是為了簡潔起見。當開發人員嘗試在音訊控制器完全初始化之前,就嘗試播放音效時,製作程式碼應妥善處理這種情況。

處理例外狀況

您或許已註意到,自己再次忽略了可能的例外狀況。為了學習,我們來修正這個特定方法。(為求簡潔,本程式碼研究室會在本節後恢復忽略例外狀況的設定)。

  • 如要處理此情況中的例外狀況,請將 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 檔案,但資產目錄中還有兩個其他版本的音效。遊戲中如果有幾種相同音效的版本,並以隨機或輪流播放的方式播放,通常會聽起來更自然。舉例來說,雙腳踏板和槍枝散發聲音才是假的,因此也能避免出現假的。

  • 您可以選擇修改程式碼,讓每次輕觸按鈕時都播放不同的禱告聲。

插圖:

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() 方法。如果無法取得部分詳細資料也沒關係。重要的是,當您選取「Start music」時,音樂就會開始播放。

以下是參考實作:

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 列舉) 是更明智的做法。

不過,您有幾個問題。首先,音樂太大聲,蓋過其他聲音。在大多數遊戲中,音樂大多都是在背景播放,因此能呈現資訊更豐富的音訊 (如語音和音效)。使用播放方法的音量參數即可輕鬆解決。舉例來說,你可以試試 _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 提供方便的「啟動後即忘」方法,可為您執行這項操作。以下說明如何在五秒內淡出音樂,然後停止音訊例項,以免不必要地消耗 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% 的回音效果輸出則會是 20%。如果將這個參數設為 1.0,就會像是只聽到從房間遠處牆壁傳回的聲波,而沒有原始音訊。
  • 將「Room Size」參數設為 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 等程式庫。其餘程式碼則各有專屬。
  • 建構並發布遊戲。

耳機插圖