在 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 檔案中,音調音效在下列檔案中:
  • 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 外掛程式。這個外掛程式以 Nintendo SNES Classic 開發的 SoLoud 專案為基礎,是一款遊戲相關的 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 會區分音訊來源 (用來描述音效的資料與中繼資料) 和音訊來源 (即實際播放的聲音的資料)。音訊來源的例子包括載入記憶體的 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. 儲存檔案、熱重重新載入,然後選取「播放音效」。你應該會聽得到輕柔的咒語。注意事項:
  • 提供的 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 程式碼。Dart 程式碼和平台程式碼之間一般的訊息往返作業現已可供找到,這是大多數 Flutter 外掛程式的特徵。某些方法具有非同步特性,唯一的原因是外掛程式的部分程式碼自行獨立執行,而 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 例外狀況),以便您找出所有與音訊引擎功能相關的錯誤。在音訊播放不重要的情況下,這項功能特別實用。舉例來說,假設您不希望玩家的遊戲工作階段異常終止,就是因為某個討厭的其中一個音效無法載入。

正如您所預期的那樣,如果您提供的素材資源索引鍵不存在,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 列舉) 是更明智的做法。

不過,你有幾個問題,首先,音樂太大聲,造成聲音過度。在大多數遊戲中,音樂大多都是在背景播放,因此能呈現資訊更豐富的音訊 (如語音和音效)。使用播放方法的音量參數即可輕鬆解決。舉例來說,你可以嘗試使用「_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 提供數種不同的音效,你可以套用至音訊。

  • 如要讓玩家在大型房間 (例如大教堂或洞穴) 中發出聲音,請使用 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% 的回音效果輸出則會是 20%。如果將這項參數設為 1.0,表示它只會聽到從房間遙遠的牆面發出的聲波,而且沒有任何原始音訊。
  • 將「Room Size」參數設為 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 等圖書館。其餘程式碼則各有專屬。
  • 建構並發布遊戲。

耳機插圖