1. Antes de comenzar
Los juegos son experiencias audiovisuales. Flutter es una gran herramienta para crear imágenes atractivas y una IU sólida, por lo que te lleva muy lejos en el aspecto visual. El ingrediente que falta es el audio. En este codelab, aprenderás a usar el complemento flutter_soloud
para incorporar sonido y música de baja latencia a tu proyecto. Comienzas con un andamiaje básico de modo que puedas pasar directamente a las partes interesantes.
Por supuesto, puedes usar lo que aprendas aquí para agregar audio a tus apps, no solo a los juegos. Sin embargo, si bien casi todos los juegos requieren sonido y música, la mayoría de las apps no, por lo que este codelab se enfoca en los juegos.
Requisitos previos
- Conocimientos básicos de Flutter
- Conocimientos para ejecutar y depurar apps de Flutter
Qué aprenderá
- Cómo reproducir sonidos únicos
- Cómo reproducir y personalizar bucles de música sin interrupciones
- Cómo atenuar y aumentar el volumen de los sonidos
- Cómo aplicar efectos ambientales a los sonidos
- Cómo lidiar con las excepciones
- Cómo encapsular todas estas funciones en un solo controlador de audio
Requisitos
- El SDK de Flutter
- El editor de código que prefieras
2. Configurar
- Descarga los siguientes archivos. Si tienes una conexión lenta, no te preocupes. Necesitarás los archivos reales más adelante, así que puedes permitir que se descarguen mientras trabajas.
- Crea un proyecto de Flutter con el nombre que elijas.
- Crea un archivo
lib/audio/audio_controller.dart
en el proyecto. - En el archivo, ingresa el siguiente código:
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
}
}
Como puedes ver, este es solo un esqueleto para la funcionalidad futura. Lo implementaremos todo durante este codelab.
- A continuación, abre el archivo
lib/main.dart
y reemplaza su contenido con el siguiente código:
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();
}
},
),
],
),
],
),
),
);
}
}
- Después de descargar los archivos de audio, crea un directorio en la raíz de tu proyecto llamado
assets
. - En el directorio
assets
, crea dos subdirectorios, uno llamadomusic
y el otro llamadosounds
. - Mueve los archivos descargados a tu proyecto para que el archivo de la canción esté en el archivo
assets/music/looped-song.ogg
y los sonidos de los bancos estén en los siguientes archivos:
assets/sounds/pew1.mp3
assets/sounds/pew2.mp3
assets/sounds/pew3.mp3
La estructura de tu proyecto debería verse de la siguiente manera:
Ahora que los archivos están allí, debes informarles a Flutter sobre ellos.
- Abre el archivo
pubspec.yaml
y, luego, reemplaza la secciónflutter:
que se encuentra en la parte inferior del archivo por lo siguiente:
pubspec.yaml
...
flutter:
uses-material-design: true
assets:
- assets/music/
- assets/sounds/
- Agrega una dependencia en el paquete
flutter_soloud
y en el paquetelogging
.
flutter pub add flutter_soloud logging
Tu archivo pubspec.yaml
ahora debería tener dependencias adicionales en los paquetes flutter_soloud
y logging
.
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
flutter_soloud: ^3.1.10
logging: ^1.3.0
...
- Ejecutar el proyecto Aún no funciona nada porque agregarás la funcionalidad en las siguientes secciones.
3. Cómo inicializar y apagar
Para reproducir audio, usa el complemento flutter_soloud
. Este complemento se basa en el proyecto SoLoud, un motor de audio C++ para juegos que usa, entre otros, Nintendo SNES Classic.
Para inicializar el motor de audio SoLoud, sigue estos pasos:
- En el archivo
audio_controller.dart
, importa el paqueteflutter_soloud
y agrega un campo_soloud
privado a la clase.
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
}
...
El controlador de audio administra el motor subyacente de SoLoud a través de este campo y reenvía todas las llamadas a él.
- En el método
initialize()
, ingresa el siguiente código:
lib/audio/audio_controller.dart
...
Future<void> initialize() async {
_soloud = SoLoud.instance;
await _soloud!.init();
}
...
Esto propaga el campo _soloud
y espera la inicialización. Ten en cuenta lo siguiente:
- SoLoud proporciona un campo
instance
singleton. No hay forma de crear instancias de varias instancias de SoLoud. El motor de C++ no permite esto, por lo que el complemento de Dart tampoco lo permite. - La inicialización del complemento es asíncrona y no finaliza hasta que se muestra el método
init()
. - Para abreviar este ejemplo, no capturas errores en un bloque
try/catch
. En el código de producción, debes hacerlo y, luego, informar cualquier error al usuario.
- En el método
dispose()
, ingresa el siguiente código:
lib/audio/audio_controller.dart
...
void dispose() {
_soloud?.deinit();
}
...
Es recomendable que cierres SoLoud cuando salgas de la app, aunque todo debería funcionar bien incluso si no lo haces.
- Observa que ya se llama al método
AudioController.initialize()
desde la funciónmain()
. Esto significa que reiniciar el proyecto en caliente inicializa SoLoud en segundo plano, pero no te servirá de nada antes de que reproduzcas algunos sonidos.
4. Reproducir sonidos únicos
Carga un recurso y reprodúcelo
Ahora que sabes que SoLoud se inicializa al inicio, puedes pedirle que reproduzca sonidos.
SoLoud diferencia entre una fuente de audio, que son los datos y metadatos que se usan para describir un sonido, y sus "instancias de sonido", que son los sonidos que se reproducen. Un ejemplo de una fuente de audio puede ser un archivo mp3 cargado en la memoria, listo para reproducirse y representado por una instancia de la clase AudioSource
. Cada vez que reproduces esta fuente de audio, SoLoud crea una "instancia de sonido" que está representada por el tipo SoundHandle
.
Para obtener una instancia de AudioSource
, debes cargarla. Por ejemplo, si tienes un archivo mp3 en tus recursos, puedes cargarlo para obtener un AudioSource
. Luego, le dices a SoLoud que reproduzca este AudioSource
. Puedes jugarlo muchas veces, incluso de forma simultánea.
Cuando termines de usar una fuente de audio, elimínala con el método SoLoud.disposeSource()
.
Para cargar un recurso y reproducirlo, sigue estos pasos:
- En el método
playSound()
de la claseAudioController
, ingresa el siguiente código:
lib/audio/audio_controller.dart
...
Future<void> playSound(String assetKey) async {
final source = await _soloud!.loadAsset(assetKey);
await _soloud!.play(source);
}
...
- Guarda el archivo, realiza la recarga en caliente y, luego, selecciona Reproducir sonido. Deberías escuchar un sonido de pew. Ten en cuenta lo siguiente:
- El argumento
assetKey
proporcionado es algo comoassets/sounds/pew1.mp3
, la misma cadena que proporcionarías a cualquier otra API de Flutter que cargue recursos, como el widgetImage.asset()
. - La instancia de SoLoud proporciona un método
loadAsset()
que carga de forma asíncrona un archivo de audio de los recursos del proyecto de Flutter y muestra una instancia de la claseAudioSource
. Existen métodos equivalentes para cargar un archivo desde el sistema de archivos (el métodoloadFile()
) y para cargarlo a través de la red desde una URL (el métodoloadUrl()
). - Luego, la instancia de
AudioSource
recién adquirida se pasa al métodoplay()
de SoLoud. Este método muestra una instancia del tipoSoundHandle
que representa el sonido que se acaba de reproducir. A su vez, este identificador se puede pasar a otros métodos de SoLoud para realizar acciones como pausar, detener o modificar el volumen del sonido. - Aunque
play()
es un método asíncrono, la reproducción comienza básicamente de forma instantánea. El paqueteflutter_soloud
usa la interfaz de función externa (FFI) de Dart para llamar al código C de forma directa y síncrona. No se encuentra en ningún lugar el intercambio de mensajes habitual entre el código de Dart y el código de la plataforma que es característico de la mayoría de los complementos de Flutter. La única razón por la que algunos métodos son asíncronos es que parte del código del complemento se ejecuta en su propio aislamiento, y la comunicación entre los aislamientos de Dart es asíncrona. - Declaras que el campo
_soloud
no es nulo con_soloud!
. Esto es, de nuevo, para abreviar. El código de producción debe controlar de forma fluida la situación en la que el desarrollador intenta reproducir un sonido antes de que el controlador de audio haya tenido la oportunidad de inicializarse por completo.
Cómo controlar excepciones
Es posible que hayas notado que, una vez más, estás ignorando posibles excepciones. Es hora de corregir eso para este método en particular con fines de aprendizaje. (Por motivos de brevedad, el codelab vuelve a ignorar las excepciones después de esta sección).
- Para controlar las excepciones en este caso, une las dos líneas del método
playSound()
en un bloquetry/catch
y solo captura instancias 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 arroja varias excepciones, como las excepciones SoLoudNotInitializedException
o SoLoudTemporaryFolderFailedException
. En la documentación de la API de cada método, se enumeran los tipos de excepciones que se pueden generar.
SoLoud también proporciona una clase superior a todas sus excepciones, la excepción SoLoudException
, para que puedas detectar todos los errores relacionados con la funcionalidad del motor de audio. Esto es especialmente útil en los casos en que no es fundamental reproducir audio. Por ejemplo, cuando no quieres que falle la sesión de juego del jugador solo porque no se pudo cargar uno de los sonidos de pew-pew.
Como es de esperar, el método loadAsset()
también puede arrojar un error FlutterError
si proporcionas una clave de activo que no existe. Por lo general, debes abordar el intento de cargar recursos que no están empaquetados con el juego, por lo que se trata de un error.
Reproducir diferentes sonidos
Es posible que hayas notado que solo reproduces el archivo pew1.mp3
, pero hay otras dos versiones del sonido en el directorio de recursos. A menudo, suena más natural cuando los juegos tienen varias versiones del mismo sonido y reproducen las diferentes versiones de forma aleatoria o de forma rotativa. Esto evita, por ejemplo, que los pasos y los disparos suenen demasiado uniformes y, por lo tanto, falsos.
- Como ejercicio opcional, modifica el código para que se reproduzca un sonido de iglesia diferente cada vez que se presione el botón.
5. Reproducir bucles de música
Cómo administrar sonidos de larga duración
Algunos audios están diseñados para reproducirse durante períodos prolongados. La música es el ejemplo más obvio, pero muchos juegos también reproducen ambientes, como el viento que silba por los pasillos, el canto lejano de los monjes, el crujido de metal centenario o las toses distantes de los pacientes.
Son fuentes de audio con tiempos de reproducción que se pueden medir en minutos. Debes hacer un seguimiento de ellos para poder pausarlos o detenerlos cuando sea necesario. A menudo, también se respaldan con archivos grandes y pueden consumir mucha memoria, por lo que otra razón para hacerles un seguimiento es que puedas descartar la instancia de AudioSource
cuando ya no sea necesaria.
Por ese motivo, agregarás un nuevo campo privado a AudioController
. Es un identificador para la canción que se está reproduciendo, si la hay. Agrega la siguiente línea:
lib/audio/audio_controller.dart
...
class AudioController {
static final Logger _log = Logger('AudioController');
SoLoud? _soloud;
SoundHandle? _musicHandle; // ← Add this.
...
Cómo iniciar música
En esencia, reproducir música no es diferente de reproducir un sonido único. Primero, debes cargar el archivo assets/music/looped-song.ogg
como una instancia de la clase AudioSource
y, luego, usar el método play()
de SoLoud para reproducirlo.
Sin embargo, esta vez, tomas el control de sonido que muestra el método play()
para manipular el audio mientras se reproduce.
- Si lo deseas, implementa el método
AudioController.startMusic()
por tu cuenta. No pasa nada si no recuerdas algunos detalles. Lo importante es que la música comience cuando selecciones Iniciar música.
A continuación, se muestra una implementación de referencia:
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,
);
}
...
Ten en cuenta que cargas el archivo de música en modo de disco (la enumeración LoadMode.disk
). Esto significa que el archivo solo se carga en fragmentos según sea necesario. Para audios de mayor duración, lo mejor es cargarlos en modo de disco. Para los efectos de sonido cortos, tiene más sentido cargarlos y descomprimirlos en la memoria (la enumeración LoadMode.memory
predeterminada).
Sin embargo, tienes algunos problemas. En primer lugar, la música es demasiado alta y predomina sobre los sonidos. En la mayoría de los juegos, la música se reproduce en segundo plano la mayor parte del tiempo, lo que permite que el audio más informativo, como la voz y los efectos de sonido, se destaque. Esto se corrige con el parámetro de volumen del método de reproducción. Por ejemplo, puedes probar _soloud!.play(musicSource, volume: 0.6)
para reproducir la canción con un volumen del 60%. Como alternativa, puedes establecer el volumen en cualquier momento con algo como _soloud!.setVolume(_musicHandle, 0.6)
.
El segundo problema es que la canción se detiene abruptamente. Esto se debe a que es una canción que se supone que se debe reproducir en un bucle y el punto de partida del bucle no es el principio del archivo de audio.
Esta es una opción popular para la música de juegos, ya que significa que la canción comienza con una introducción natural y, luego, se reproduce todo el tiempo que sea necesario sin un punto de bucle obvio. Cuando el juego necesita hacer la transición de la canción que se está reproduciendo, la atenúa.
Por suerte, SoLoud ofrece formas de reproducir audio en bucle. El método play()
toma un valor booleano para el parámetro looping
y también el valor del punto inicial del bucle como el parámetro loopingStartAt
. El código resultante se ve así:
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 no estableces el parámetro loopingStartAt
, el valor predeterminado es Duration.zero
(en otras palabras, el inicio del archivo de audio). Si tienes una pista de música que es un bucle perfecto sin ninguna introducción, esta es la opción que quieres.
- Para verificar que la fuente de audio se elimine correctamente una vez que termine de reproducirse, escucha la transmisión
allInstancesFinished
que proporciona cada fuente de audio. Con las llamadas de registro agregadas, el métodostartMusic()
se ve de la siguiente manera:
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),
);
}
...
Atenuar sonido
El siguiente problema es que la música nunca termina. Es hora de implementar un desvanecimiento.
Una forma de implementar la atenuación sería tener algún tipo de función a la que se llame varias veces por segundo, como Ticker
o Timer.periodic
, y bajar el volumen de la música en pequeños decrementos. Esto funcionaría, pero requiere mucho trabajo.
Por suerte, SoLoud proporciona métodos convenientes de "activar y olvidar" que hacen esto por ti. A continuación, te mostramos cómo puedes atenuar la música durante cinco segundos y, luego, detener la instancia de sonido para que no consuma recursos de la CPU de forma innecesaria. Reemplaza el método fadeOutMusic()
por este código:
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. Cómo aplicar efectos
Una gran ventaja de tener un motor de audio adecuado a tu disposición es que puedes realizar el procesamiento de audio, como enrutar algunos sonidos a través de una reverberación, un ecualizador o un filtro de paso bajo.
En los juegos, se puede usar para la diferenciación auditiva de las ubicaciones. Por ejemplo, un aplauso suena diferente en un bosque que en un búnker de hormigón. Mientras que un bosque ayuda a disipar y absorber el sonido, las paredes desnudas de un búnker reflejan las ondas sonoras, lo que genera reverberación. Del mismo modo, las voces de las personas suenan diferentes cuando se escuchan a través de una pared. Las frecuencias más altas de esos sonidos se atenúan más a medida que viajan a través del medio sólido, lo que genera un efecto de filtro pasa-bajo.
SoLoud proporciona varios efectos de audio diferentes que puedes aplicar al audio.
- Para que suene como si el reproductor estuviera en una sala grande, como una catedral o una cueva, usa el campo
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();
}
...
El campo SoLoud.filters
te brinda acceso a todos los tipos de filtros y sus parámetros. Cada parámetro también tiene funciones integradas, como la atenuación gradual y la oscilación.
Nota: _soloud!.filters
expone filtros globales. Si deseas aplicar filtros a una sola fuente, usa el equivalente AudioSource.filters
, que actúa de la misma manera.
Con el código anterior, haz lo siguiente:
- Habilita el filtro freeverb de forma global.
- Establece el parámetro Wet en
0.2
, lo que significa que el audio resultante será un 80% original y un 20% del resultado del efecto de reverberación. Si configuras este parámetro en1.0
, sería como escuchar solo las ondas de sonido que te llegan desde las paredes distantes de la habitación y nada del audio original. - Establece el parámetro Room Size en
0.9
. Puedes ajustar este parámetro a tu gusto o incluso cambiarlo de forma dinámica.1.0
es una caverna enorme, mientras que0.0
es un baño.
- Si quieres, cambia el código y aplica uno de los siguientes filtros o una combinación de ellos:
biquadFilter
(se puede usar como filtro de paso bajo)pitchShiftFilter
equalizerFilter
echoFilter
lofiFilter
flangerFilter
bassboostFilter
waveShaperFilter
robotizeFilter
7. Felicitaciones
Implementaste un controlador de audio que reproduce sonidos, repite música y aplica efectos.
Más información
- Intenta aprovechar al máximo el controlador de audio con funciones como la carga previa de sonidos al inicio, la reproducción de canciones en una secuencia o la aplicación de un filtro gradualmente con el tiempo.
- Lee la documentación del paquete de
flutter_soloud
. - Lee la página principal de la biblioteca C++ subyacente.
- Obtén más información sobre la FFI de Dart, la tecnología que se usa para interactuar con la biblioteca de C++.
- Mira la conferencia de Guy Somberg sobre la programación de audio de juegos para inspirarte. (También hay una versión más larga). Cuando Guy habla de "middleware", se refiere a bibliotecas como SoLoud y FMOD. El resto del código suele ser específico de cada juego.
- Compila y lanza tu juego.