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 intégrer des sons 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.
Vous pouvez bien sûr utiliser les connaissances acquises ici pour ajouter du contenu 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
- Savoir exécuter et déboguer des applications Flutter
Objectifs
- Lire des sons ponctuels
- Découvrez comment diffuser et personnaliser des boucles musicales sans interruption.
- Comment ajouter et supprimer des sons en fondu ?
- Comment appliquer des effets environnementaux à des 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),
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();
}
},
),
],
),
],
),
),
);
}
}
- 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 nommé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 maintenant ressembler à ceci:
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
.
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
flutter_soloud: ^2.0.0
logging: ^1.2.0
...
- Exécuter le projet Pour le moment, rien ne fonctionne, car vous avez ajouté la fonctionnalité dans les sections suivantes.
/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];
Ceux-ci proviennent de la bibliothèque C++ SoLoud
sous-jacente. Elles n'ont aucune incidence sur les fonctionnalités et peuvent être ignorées en toute sécurité.
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: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
}
...
Le contrôleur audio gère le moteur SoLoud sous-jacent via ce champ et lui transmet tous les appels.
- 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 Singleton
instance
. Il n'existe aucun moyen 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 souhaitez 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 fermeture de l'application, même si tout devrait fonctionner correctement, même si vous négligez de le faire.
- 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. Il peut s'agir, par exemple, d'un fichier mp3 chargé dans la mémoire, prêt à être lu et représenté par une instance de la classe AudioSource
. Chaque fois que vous lancez cette source audio, SoLoud crée une "instance de son". qui est représenté 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 n'avez plus besoin d'une source audio, vous la supprimez à 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 hot reload, puis sélectionnez Faire sonner. Vous devriez entendre un son de siège. Veuillez noter les points suivants :
- L'argument
assetKey
fourni se présente sous la formeassets/sounds/pew1.mp3
, soit la même chaîne que celle que vous fourniriez à toute autre API Flutter de chargement d'é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 habituels entre le code Dart et le code de la plate-forme, qui sont propres à la plupart des plug-ins Flutter, sont introuvables. Certaines méthodes sont asynchrones, car une partie du code du plug-in s'exécute dans son propre élément isolé, et la communication entre les isols Dart est asynchrone. - Vous affirmez simplement que le champ
_soloud
n'est pas nul avec_soloud!
. Encore une fois, par souci de concision. Le code de production doit pouvoir gérer de manière optimale le cas où le développeur tente d'émettre un son avant que la commande audio n'ait pu s'initialiser complètement.
Gérer les exceptions
Vous avez peut-être remarqué que vous ignoriez à nouveau les exceptions possibles. À des fins d'apprentissage, corrigeons ce problème pour cette méthode particulière. (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 relative à l'API de chaque méthode répertorie les types d'exceptions qui peuvent être générés.
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 lorsqu'il n'est pas nécessaire de lire le contenu audio. C'est le cas, par exemple, lorsque vous ne voulez pas faire planter la session de jeu du joueur uniquement parce que l'un des sons du banc d'exercice n'a pas pu être chargé.
Comme prévu, la méthode loadAsset()
peut également générer une erreur FlutterError
si vous fournissez une clé d'élément qui n'existe pas. Vous devez généralement résoudre les problèmes de chargement d'éléments qui ne sont pas regroupés avec le jeu. Il s'agit donc d'une erreur.
Lire 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. Cela semble souvent plus naturel lorsque les jeux ont plusieurs versions du même son et jouent les différentes versions de manière aléatoire ou alternée. Cela permet, par exemple, d'éviter que les pas et les coups de feu ne semblent trop uniformes et donc faux.
- Vous pouvez aussi modifier le code afin d'émettre un son de banc différent chaque fois que l'utilisateur appuie 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 sons d'ambiance, 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 peut être mesurée en minutes. Vous devez en garder une trace afin de pouvoir les mettre en pause ou les arrêter si nécessaire. Elles sont également souvent basées sur de grands fichiers et peuvent consommer beaucoup de mémoire. Une autre raison de les suivre est que vous pouvez vous débarrasser de l'instance AudioSource
lorsqu'elle n'est plus nécessaire.
Pour cette raison, vous allez introduire un nouveau champ privé pour AudioController
. Il s'agit d'un handle pour le 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 n'est pas différente de la diffusion d'un son one-shot. Vous devez 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 vous-même la méthode
AudioController.startMusic()
. 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);
_musicHandle = await _soloud!.play(musicSource);
}
...
Notez que vous chargez le fichier musical en mode disque (énumération LoadMode.disk
). Cela signifie simplement que le fichier n'est chargé en fragments que si nécessaire. Pour les contenus audio plus longs, il est généralement préférable de les charger en mode disque. Pour les effets sonores courts, il est plus logique de les charger et de les décompresser en mémoire (l'énumération LoadMode.memory
par défaut).
Cependant, vous avez quelques problèmes. Tout d'abord, le volume de la musique est trop élevé, ce qui perturbe le son. 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. Ce problème peut être facilement résolu à l'aide 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 à un moment ultérieur avec une commande comme _soloud!.setVolume(_musicHandle, 0.6)
.
Le deuxième problème est que le titre s'arrête brusquement. En effet, le titre est 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 est lu aussi longtemps que nécessaire, sans point de boucle évidente. Lorsque le jeu doit quitter le titre en cours de lecture, il l'efface en fondu.
Heureusement, SoLoud propose des options de lecture de 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 avez un morceau dont la boucle est parfaite sans aucune introduction, c'est l'option que vous voulez.
- Pour vous assurer que la source audio sera correctement supprimée une fois la lecture terminée, écoutez le flux
allInstancesFinished
fourni par chaque source audio. Une fois les appels de journaux 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. Implémentons un fondu.
Pour implémenter le fondu, vous pouvez utiliser une sorte de fonction appelée plusieurs fois par seconde, comme Ticker
ou Timer.periodic
, et baisser légèrement le volume de la musique. Cela fonctionnerait, mais cela représente beaucoup de travail.
Heureusement, SoLoud propose des méthodes "fire and forget" pratiques 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'avoir un moteur audio approprié à votre disposition est que vous pouvez effectuer du traitement audio, par exemple en acheminant certains sons par une réverbération, un égaliseur ou un filtre passe-bas.
Dans les jeux, elle peut servir à différencier les lieux de manière auditive. Par exemple, le son des clap est différent dans une forêt et 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 facilement atténuées à mesure qu'elles traversent le milieu solide, ce qui génère 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 intègre également des fonctionnalités telles que le fondu et l'oscillation progressifs.
Remarque : _soloud!.filters
expose les filtres globaux. Si vous souhaitez appliquer des filtres à une seule source, veuillez utiliser l'élément AudioSource.filters
correspondant, qui fonctionne de la même manière.
Avec le code précédent, vous effectuez les opérations suivantes:
- Activez le filtre freeverb de manière globale.
- Définissez le paramètre Wet sur
0.2
. Ainsi, le son sera à 80% d'origine et à 20% de la sortie de l'effet de réverbération. Si vous définissez ce paramètre sur1.0
, cela revient à n'entendre que les ondes sonores qui reviennent vers vous depuis les murs de la pièce au loin, et non le son d'origine. - Définissez le paramètre 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 des filtres suivants:
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'aller encore plus loin avec la commande audio avec des fonctionnalités comme le préchargement des sons au démarrage, la lecture de chansons dans une séquence ou l'application progressive d'un filtre au fil du temps.
- Lisez la documentation sur le 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++
- Inspirez-vous de la présentation de Guy Somberg sur la programmation audio pour les jeux vidéo. (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 est généralement propre à chaque jeu.
- Créez votre jeu et publiez-le.