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

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

程式碼研究室簡介

subject上次更新時間:6月 6, 2025
account_circle作者:Filip Hracek

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),
     
),
     
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` 等檔案。其中,我們可以看到 `assets` 目錄包含 `music` 和 `sounds` 子目錄,`lib` 目錄包含 `main.dart`,`audio` 子目錄包含 `audio_controller.dart`,以及 `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 專案為基礎,這是一款遊戲音訊引擎,除了 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 是良好的做法,但即使您未關閉 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 方法,執行暫停、停止或修改音量等操作。
  • 雖然 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 檔案,但資產目錄中還有兩個其他版本的音效。遊戲中如果有幾種相同音效的版本,並以隨機或輪流播放的方式播放,通常會聽起來更自然。例如,這樣做可避免腳步聲和槍聲聽起來太過一致,因此聽起來不自然。

  • 您可以選擇修改程式碼,讓每次輕觸按鈕時都播放不同的 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,
    );
  }

...

請注意,您是在磁碟模式 (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% 的混響效果輸出內容。如果將這個參數設為 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 等程式庫。程式碼的其餘部分通常是各遊戲專屬。
  • 建構並發布遊戲。

耳機插圖