1. Avant de commencer
Les jeux sont des expériences audiovisuelles. Flutter est un excellent outil pour créer des visuels attrayants et une interface utilisateur solide. Il vous permet donc de créer des éléments visuels de qualité. L'ingrédient manquant est l'audio. Dans cet atelier de programmation, vous allez apprendre à utiliser le plug-in flutter_soloud
pour ajouter du son et de la musique à faible latence à votre projet. Vous commencez par créer une structure de base vous permettant de passer directement aux parties intéressantes.
Bien entendu, vous pouvez utiliser ce que vous apprenez ici pour ajouter de l'audio à vos applications, et pas seulement à vos jeux. Cependant, alors que presque tous les jeux nécessitent du son et de la musique, la plupart des applications ne le font pas. Cet atelier de programmation se concentre donc sur les jeux.
Prérequis
- Connaissances de base de Flutter
- Vous devez savoir exécuter et déboguer des applications Flutter.
Objectifs
- Lire des sons ponctuels
- Lire et personnaliser des boucles musicales sans interruption
- Comment faire monter et baisser le son
- Appliquer des effets environnementaux aux sons
- Gérer les exceptions
- Encapsuler toutes ces fonctionnalités dans un seul contrôleur audio
Ce dont vous avez besoin
- Le SDK Flutter
- Un éditeur de code de votre choix
2. Configurer
- Téléchargez les fichiers suivants. Si votre connexion est lente, ne vous inquiétez pas. Vous aurez besoin des fichiers plus tard, vous pouvez donc les laisser télécharger pendant que vous travaillez.
- Créez un projet Flutter avec le nom de votre choix.
- Créez un fichier
lib/audio/audio_controller.dart
dans le projet. - Dans le fichier, saisissez le code suivant :
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
}
}
Comme vous pouvez le constater, il ne s'agit que d'un squelette de la future fonctionnalité. Nous allons tout implémenter au cours de cet atelier de programmation.
- Ouvrez ensuite le fichier
lib/main.dart
, puis remplacez son contenu par le code suivant:
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();
}
},
),
],
),
],
),
),
);
}
}
- Une fois les fichiers audio téléchargés, créez un répertoire nommé
assets
dans la racine de votre projet. - Dans le répertoire
assets
, créez deux sous-répertoires, l'un appelémusic
et l'autresounds
. - Déplacez les fichiers téléchargés vers votre projet afin que le fichier de la chanson se trouve dans le fichier
assets/music/looped-song.ogg
et que les sons de bancs se trouvent dans les fichiers suivants:
assets/sounds/pew1.mp3
assets/sounds/pew2.mp3
assets/sounds/pew3.mp3
La structure de votre projet devrait se présenter comme suit:
Maintenant que les fichiers sont là, vous devez en informer Flutter.
- Ouvrez le fichier
pubspec.yaml
, puis remplacez la sectionflutter:
en bas du fichier par ce qui suit:
pubspec.yaml
...
flutter:
uses-material-design: true
assets:
- assets/music/
- assets/sounds/
- Ajoutez une dépendance au package
flutter_soloud
et au packagelogging
.
flutter pub add flutter_soloud logging
Votre fichier pubspec.yaml
doit désormais comporter des dépendances supplémentaires sur les packages flutter_soloud
et logging
.
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
flutter_soloud: ^3.1.10
logging: ^1.3.0
...
- Exécuter le projet Rien ne fonctionne encore, car vous allez ajouter la fonctionnalité dans les sections suivantes.
3. Initialiser et arrêter
Pour lire de l'audio, vous devez utiliser le plug-in flutter_soloud
. Ce plug-in est basé sur le projet SoLoud, un moteur audio C++ pour les jeux utilisé, entre autres, par la Nintendo SNES Classic.
Pour initialiser le moteur audio SoLoud, procédez comme suit:
- Dans le fichier
audio_controller.dart
, importez le packageflutter_soloud
et ajoutez un champ_soloud
privé à la classe.
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
}
...
Le contrôleur audio gère le moteur SoLoud sous-jacent via ce champ et transfère tous les appels vers celui-ci.
- Dans la méthode
initialize()
, saisissez le code suivant:
lib/audio/audio_controller.dart
...
Future<void> initialize() async {
_soloud = SoLoud.instance;
await _soloud!.init();
}
...
Le champ _soloud
est renseigné et l'initialisation est attendue. Veuillez noter les points suivants :
- SoLoud fournit un champ
instance
singleton. Il n'est pas possible d'instancier plusieurs instances SoLoud. Le moteur C++ n'autorise pas cela, et le plug-in Dart non plus. - L'initialisation du plug-in est asynchrone et n'est pas terminée tant que la méthode
init()
ne renvoie pas de résultat. - Par souci de concision, dans cet exemple, vous ne détectez pas les erreurs dans un bloc
try/catch
. Dans le code de production, vous devez le faire et signaler toute erreur à l'utilisateur.
- Dans la méthode
dispose()
, saisissez le code suivant:
lib/audio/audio_controller.dart
...
void dispose() {
_soloud?.deinit();
}
...
Il est recommandé d'arrêter SoLoud à la sortie de l'application, même si tout devrait fonctionner correctement si vous ne le faites pas.
- Notez que la méthode
AudioController.initialize()
est déjà appelée depuis la fonctionmain()
. Cela signifie que le redémarrage à chaud du projet initialise SoLoud en arrière-plan, mais que cela ne vous sera d'aucune utilité avant de lire des sons.
4. Lire des sons ponctuels
Charger un élément et le lire
Maintenant que vous savez que SoLoud est initialisé au démarrage, vous pouvez lui demander de diffuser des sons.
SoLoud fait la distinction entre une source audio, qui correspond aux données et aux métadonnées utilisées pour décrire un son, et ses "instances de son", qui sont les sons réellement lus. Une source audio peut être un fichier MP3 chargé en mémoire, prêt à être lu et représenté par une instance de la classe AudioSource
. Chaque fois que vous lisez cette source audio, SoLoud crée une "instance de son" représentée par le type SoundHandle
.
Vous obtenez une instance AudioSource
en la chargeant. Par exemple, si vous disposez d'un fichier MP3 dans vos composants, vous pouvez le charger pour obtenir un AudioSource
. Vous demandez ensuite à SoLoud de lire ce AudioSource
. Vous pouvez y jouer plusieurs fois, même simultanément.
Lorsque vous avez terminé d'utiliser une source audio, vous devez vous en débarrasser à l'aide de la méthode SoLoud.disposeSource()
.
Pour charger un composant et le lire, procédez comme suit:
- Dans la méthode
playSound()
de la classeAudioController
, saisissez le code suivant:
lib/audio/audio_controller.dart
...
Future<void> playSound(String assetKey) async {
final source = await _soloud!.loadAsset(assetKey);
await _soloud!.play(source);
}
...
- Enregistrez le fichier, effectuez un rechargement à chaud, puis sélectionnez Lire le son. Vous devriez entendre un son de siège de cinéma. Veuillez noter les points suivants :
- L'argument
assetKey
fourni est quelque chose commeassets/sounds/pew1.mp3
, la même chaîne que vous fourniriez à toute autre API Flutter chargée de charger des éléments, comme le widgetImage.asset()
. - L'instance SoLoud fournit une méthode
loadAsset()
qui charge de manière asynchrone un fichier audio à partir des éléments du projet Flutter et renvoie une instance de la classeAudioSource
. Il existe des méthodes équivalentes pour charger un fichier à partir du système de fichiers (méthodeloadFile()
) et pour le charger sur le réseau à partir d'une URL (méthodeloadUrl()
). - L'instance
AudioSource
nouvellement acquise est ensuite transmise à la méthodeplay()
de SoLoud. Cette méthode renvoie une instance du typeSoundHandle
qui représente le son nouvellement lu. Ce handle peut ensuite être transmis à d'autres méthodes SoLoud pour effectuer des actions telles que la mise en pause, l'arrêt ou le réglage du volume du son. - Bien que
play()
soit une méthode asynchrone, la lecture commence pratiquement instantanément. Le packageflutter_soloud
utilise l'interface de fonction étrangère (FFI) de Dart pour appeler le code C directement et de manière synchrone. Les échanges de messages habituels entre le code Dart et le code de la plate-forme, qui sont caractéristiques de la plupart des plug-ins Flutter, sont introuvables. La seule raison pour laquelle certaines méthodes sont asynchrones est que certaines parties du code du plug-in s'exécutent dans leur propre isolate, et que la communication entre les isolates Dart est asynchrone. - Vous affirmez que le champ
_soloud
n'est pas nul avec_soloud!
. Il s'agit, là encore, de concision. Le code de production doit gérer correctement la situation lorsque le développeur tente de lire un son avant que le contrôleur audio n'ait eu le temps de s'initialiser complètement.
Gérer les exceptions
Vous avez peut-être remarqué que vous ignoriez à nouveau les exceptions possibles. Il est temps de corriger cela pour cette méthode particulière à des fins d'apprentissage. (Par souci de concision, l'atelier de programmation revient à ignorer les exceptions après cette section.)
- Pour gérer les exceptions dans ce cas, encapsulez les deux lignes de la méthode
playSound()
dans un bloctry/catch
et n'interceptez que les instances deSoLoudException
.
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 génère diverses exceptions, telles que les exceptions SoLoudNotInitializedException
ou SoLoudTemporaryFolderFailedException
. La documentation de l'API de chaque méthode indique les types d'exceptions susceptibles d'être levées.
SoLoud fournit également une classe parente à toutes ses exceptions, l'exception SoLoudException
, afin que vous puissiez intercepter toutes les erreurs liées aux fonctionnalités du moteur audio. Cela est particulièrement utile lorsque la lecture audio n'est pas essentielle. Par exemple, lorsque vous ne souhaitez pas planter la session de jeu du joueur uniquement parce qu'un des sons de tir n'a pas pu être chargé.
Comme vous vous en doutez probablement, la méthode loadAsset()
peut également générer une erreur FlutterError
si vous fournissez une clé d'élément qui n'existe pas. Essayer de charger des éléments qui ne sont pas groupés avec le jeu est généralement un problème que vous devez résoudre. Il s'agit donc d'une erreur.
Émettre différents sons
Vous avez peut-être remarqué que vous ne lisez que le fichier pew1.mp3
, mais il existe deux autres versions du son dans le répertoire des éléments. Les jeux qui comportent plusieurs versions du même son et qui les diffusent de manière aléatoire ou en alternance paraissent souvent plus naturels. Cela évite, par exemple, que les pas et les coups de feu ne sonnent trop uniformément et donc de manière factice.
- À titre d'exercice facultatif, modifiez le code pour diffuser un son de banc différent chaque fois que vous appuyez sur le bouton.
5. Lire des boucles musicales
Gérer les sons de longue durée
Certains contenus audio sont destinés à être diffusés pendant de longues périodes. La musique est l'exemple le plus évident, mais de nombreux jeux diffusent également des ambiances, comme le hurlement du vent dans les couloirs, le chant lointain de moines, le grincement de métal centenaire ou la toux lointaine de patients.
Il s'agit de sources audio dont la durée de lecture peut être mesurée en minutes. Vous devez les suivre pour pouvoir les mettre en pause ou les arrêter si nécessaire. Elles sont également souvent basées sur de gros fichiers et peuvent consommer beaucoup de mémoire. Une autre raison de les suivre est donc de pouvoir vous débarrasser de l'instance AudioSource
lorsqu'elle n'est plus nécessaire.
Pour cette raison, vous allez ajouter un nouveau champ privé à AudioController
. Il s'agit d'un identifiant du titre en cours de lecture, le cas échéant. Ajoutez la ligne suivante :
lib/audio/audio_controller.dart
...
class AudioController {
static final Logger _log = Logger('AudioController');
SoLoud? _soloud;
SoundHandle? _musicHandle; // ← Add this.
...
Lancer la musique
En substance, la lecture de musique ne diffère pas de la lecture d'un son ponctuel. Vous devez toujours d'abord charger le fichier assets/music/looped-song.ogg
en tant qu'instance de la classe AudioSource
, puis utiliser la méthode play()
de SoLoud pour le lire.
Cette fois, vous prenez le contrôle du conteneur audio renvoyé par la méthode play()
pour manipuler l'audio pendant sa lecture.
- Si vous le souhaitez, implémentez la méthode
AudioController.startMusic()
vous-même. Ne vous inquiétez pas si vous ne comprenez pas certains détails. L'essentiel est que la musique commence lorsque vous sélectionnez Démarrer la musique.
Voici une implémentation de référence:
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,
);
}
...
Notez que vous chargez le fichier musical en mode disque (énumération LoadMode.disk
). Cela signifie que le fichier n'est chargé que par blocs selon les besoins. Pour les contenus audio plus longs, il est généralement préférable de les charger en mode disque. Pour les courts effets sonores, il est plus logique de les charger et de les décompresser dans la mémoire (énumération LoadMode.memory
par défaut).
Vous rencontrez toutefois quelques problèmes. Tout d'abord, la musique est trop forte et éclipse les sons. Dans la plupart des jeux, la musique est en arrière-plan la plupart du temps, laissant la place aux éléments audio plus informatifs, comme les voix et les effets sonores. Cela permet de corriger l'utilisation du paramètre de volume de la méthode de lecture. Vous pouvez par exemple essayer _soloud!.play(musicSource, volume: 0.6)
pour lire le titre à 60% du volume. Vous pouvez également définir le volume ultérieurement avec une commande comme _soloud!.setVolume(_musicHandle, 0.6)
.
Le deuxième problème est que le titre s'arrête brusquement. En effet, il s'agit d'un titre censé être lu en boucle, et le point de départ de la boucle n'est pas le début du fichier audio.
C'est un choix populaire pour la musique de jeu, car cela signifie que le titre commence par une introduction naturelle, puis se lit aussi longtemps que nécessaire sans point de boucle évident. Lorsque le jeu doit passer d'un titre à un autre, le titre en cours de lecture est progressivement coupé.
Heureusement, SoLoud propose des moyens de lire des contenus audio en boucle. La méthode play()
prend une valeur booléenne pour le paramètre looping
, ainsi que la valeur du point de départ de la boucle en tant que paramètre loopingStartAt
. Le code obtenu se présente comme suit:
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),
);
...
Si vous ne définissez pas le paramètre loopingStartAt
, il est défini par défaut sur Duration.zero
(autrement dit, au début du fichier audio). Si vous disposez d'un titre musical en boucle parfaite sans introduction, c'est ce que vous devez choisir.
- Pour vérifier que la source audio est correctement supprimée une fois la lecture terminée, écoutez le flux
allInstancesFinished
fourni par chaque source audio. Avec les appels de journalisation ajoutés, la méthodestartMusic()
se présente comme suit:
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),
);
}
...
Fondu sonore
Le problème suivant est que la musique ne s'arrête jamais. Il est temps d'implémenter un fondu.
Pour implémenter le fondu, vous pouvez utiliser une fonction appelée plusieurs fois par seconde, comme Ticker
ou Timer.periodic
, et baisser le volume de la musique par petites incréments. Cette méthode fonctionne, mais demande beaucoup de travail.
Heureusement, SoLoud fournit des méthodes pratiques de type "lancer et oublier" qui le font pour vous. Voici comment atténuer la musique sur cinq secondes, puis arrêter l'instance audio pour qu'elle ne consomme pas inutilement de ressources de processeur. Remplacez la méthode fadeOutMusic()
par le code suivant:
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. Appliquer des effets
L'un des grands avantages d'un moteur audio approprié est que vous pouvez effectuer un traitement audio, par exemple en acheminant certains sons via une réverbération, un égaliseur ou un filtre passe-bas.
Dans les jeux, cela peut servir à différencier les emplacements de manière auditive. Par exemple, un claquement sonne différemment dans une forêt que dans un bunker en béton. Alors qu'une forêt aide à dissiper et à absorber le son, les murs nus d'un bunker renvoient les ondes sonores, ce qui entraîne une réverbération. De même, la voix des personnes sonne différemment lorsqu'elle est entendue à travers un mur. Les fréquences les plus élevées de ces sons sont plus atténuées lorsqu'elles traversent le milieu solide, ce qui entraîne un effet de filtre passe-bas.
SoLoud propose plusieurs effets audio que vous pouvez appliquer à l'audio.
- Pour donner l'impression que votre joueur se trouve dans une grande pièce, comme une cathédrale ou une grotte, utilisez le champ
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();
}
...
Le champ SoLoud.filters
vous permet d'accéder à tous les types de filtres et à leurs paramètres. Chaque paramètre dispose également de fonctionnalités intégrées, comme l'atténuation progressive et l'oscillation.
Remarque : _soloud!.filters
expose les filtres globaux. Si vous souhaitez appliquer des filtres à une seule source, utilisez l'élément AudioSource.filters
correspondant, qui fonctionne de la même manière.
Avec le code précédent, procédez comme suit:
- Activez le filtre freeverb de manière globale.
- Définissez le paramètre Wet sur
0.2
. Cela signifie que l'audio obtenu sera composé à 80% de l'audio d'origine et à 20% de la sortie de l'effet de réverbération. Si vous définissez ce paramètre sur1.0
, vous n'entendrez que les ondes sonores qui vous reviennent des murs éloignés de la pièce, et aucune de l'audio d'origine. - Définissez le paramètre Room Size (Taille de la pièce) sur
0.9
. Vous pouvez ajuster ce paramètre à votre guise ou même le modifier de manière dynamique.1.0
est une immense caverne, tandis que0.0
est une salle de bain.
- Si vous le souhaitez, modifiez le code et appliquez l'un des filtres suivants ou une combinaison de filtres:
biquadFilter
(peut être utilisé comme filtre passe-bas)pitchShiftFilter
equalizerFilter
echoFilter
lofiFilter
flangerFilter
bassboostFilter
waveShaperFilter
robotizeFilter
7. Félicitations
Vous avez implémenté un contrôleur audio qui lit des sons, met en boucle de la musique et applique des effets.
En savoir plus
- Essayez d'exploiter tout le potentiel du contrôleur audio avec des fonctionnalités telles que le préchargement de sons au démarrage, la lecture de titres dans une séquence ou l'application d'un filtre progressivement au fil du temps.
- Consultez la documentation du package de
flutter_soloud
. - Consultez la page d'accueil de la bibliothèque C++ sous-jacente.
- En savoir plus sur la FFI Dart, la technologie utilisée pour l'interface avec la bibliothèque C++
- Pour trouver l'inspiration, regardez la conférence de Guy Somberg sur la programmation audio de jeux. (Il existe également une version plus longue.) Lorsque Guy parle de "middleware", il fait référence à des bibliothèques telles que SoLoud et FMOD. Le reste du code tend à être spécifique à chaque jeu.
- Créez votre jeu et publiez-le.