1. Antes de comenzar
Los juegos son experiencias audiovisuales. Flutter es una herramienta excelente para crear imágenes atractivas y una IU sólida que te acerca mucho más al 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 sobre Flutter
- Conocimientos para ejecutar y depurar apps de Flutter
Qué aprenderá
- Cómo reproducir sonidos de un solo intento
- Cómo reproducir y personalizar bucles de música sin espacios
- Cómo atenuar y aumentar el volumen de los sonidos
- Cómo aplicar efectos ambientales a los sonidos
- Cómo abordar 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),
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();
}
},
),
],
),
],
),
),
);
}
}
- 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 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:
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
.
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
flutter_soloud: ^2.0.0
logging: ^1.2.0
...
- Ejecutar el proyecto Aún no funciona nada porque agregarás la funcionalidad en las siguientes secciones.
/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];
Estos provienen de la biblioteca C++ subyacente de SoLoud
. No afectan la funcionalidad y se pueden ignorar de forma segura.
3. Inicializa y cierra
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: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
}
...
El controlador de audio administra el motor de SoLoud subyacente a través de este campo y desviará todas las llamadas hacia é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. Esto no es algo que permite el motor de C++, 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 e informar cualquier error al usuario.
- En el método
dispose()
, ingresa el siguiente código:
lib/audio/audio_controller.dart
...
void dispose() {
_soloud?.deinit();
}
...
Se recomienda cerrar SoLoud al salir de la app, aunque todo debería funcionar bien incluso si no lo haces.
- Ten en cuenta que el método
AudioController.initialize()
ya se llama desde la funciónmain()
. Esto significa que el reinicio en caliente del proyecto inicializa SoLoud en segundo plano, pero no te hará ningún bien antes de reproducir 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 realmente se reprodujeron. 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 se representa con el tipo SoundHandle
.
Para obtener una instancia de AudioSource
, cárgala. 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 reproducirlo muchas veces, incluso al mismo tiempo.
Cuando termines de usar una fuente de audio, elimínala con el método SoLoud.disposeSource()
.
Para cargar un elemento 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 tonto. Ten en cuenta lo siguiente:
- El argumento
assetKey
proporcionado es similar aassets/sounds/pew1.mp3
, la misma cadena que le darías a cualquier otra API de Flutter que cargue elementos, como el widgetImage.asset()
. - La instancia de SoLoud proporciona un método
loadAsset()
que carga de forma asíncrona un archivo de audio desde 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. Este controlador, a su vez, puede pasarse a otros métodos de SoLoud para realizar acciones como pausar, detener o modificar el volumen del sonido. - Si bien
play()
es un método asíncrono, la reproducción se inicia de forma prácticamente 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 encuentran en ninguna parte los mensajes habituales entre el código Dart y el código de la plataforma que son característicos de la mayoría de los complementos de Flutter. El único motivo por el 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 elementos aislados de Dart es asíncrona. - Solo debes afirmar que el campo
_soloud
no es nulo con_soloud!
. Esto es, de nuevo, por brevedad. El código de producción debe abordar correctamente 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. Para fines de aprendizaje, corrijamos esto en este método en particular. (Por cuestiones de brevedad, el codelab vuelve a ignorar 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
. Los documentos de API de cada método enumeran los tipos de excepciones que se podrían 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 se bloquee 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. Suele sonar más natural cuando los juegos tienen varias versiones del mismo sonido y las reproducen de forma aleatoria o 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 ejecución prolongada
Algunos audios están diseñados para reproducirse durante períodos prolongados. La música es el ejemplo obvio, pero muchos juegos también juegan a la atmósfera, como el viento aullando por pasillos, los cantos distantes de los monjes, el crujido de metales centenarios o la tos distante 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 detenerlos 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, introducirás un nuevo campo privado en AudioController
. Es un identificador para la canción que se está reproduciendo, si corresponde. 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 a reproducir un sonido de un solo intento. 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, tomarás el controlador 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 te preocupes si no proporcionas algunos detalles correctamente. 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);
_musicHandle = await _soloud!.play(musicSource);
}
...
Ten en cuenta que cargarás el archivo de música en modo de disco (la enumeración LoadMode.disk
). Esto simplemente significa que el archivo solo se carga en fragmentos según sea necesario. Para un audio de ejecución más prolongada, por lo general, es mejor cargarlo en modo de disco. En el caso de 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 fuerte y domina los sonidos. En la mayoría de los juegos, la música se encuentra en segundo plano la mayor parte del tiempo, lo que da protagonismo al audio más informativo, como el habla y los efectos de sonido. Esto se puede solucionar fácilmente 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 se trata de una canción que se supone que se reproduce en bucle y que 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 videojuegos porque significa que la canción comienza con una introducción natural y, luego, se reproduce el tiempo que sea necesario sin un punto evidente en el bucle. Cuando el juego necesita hacer la transición de la canción que se está reproduciendo, simplemente 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 configuras 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 asegurarte de 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 verá 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
Tu próximo problema es que la música nunca termina. Implementemos un fundido.
Una forma de implementar la atenuación sería tener algún tipo de función a la que se le 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. Aquí te mostramos cómo puedes hacer que la música se atenúe en el transcurso de cinco segundos y luego detener la instancia de sonido para que no consuma recursos de CPU innecesariamente. 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. Aplica 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, esto 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 un bosque ayuda a disipar y absorber el sonido, las paredes desnudas de un búnker reflejan las ondas de sonido, 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 fácilmente a medida que viajan a través del medio sólido, lo que da como resultado un efecto de filtro de paso bajo.
SoLoud ofrece varios efectos de audio diferentes, que se pueden 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 da acceso a todos los tipos de filtro 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 quieres 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% de la salida del efecto de reverberación. Si estableces este parámetro en1.0
, sería como escuchar solo las ondas de sonido que regresan a ti desde las paredes distantes de la habitación y ninguna parte 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 gran caverna, 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
- Aprovecha al máximo el control de audio con funciones como la precarga de sonidos en el inicio, la reproducción de canciones en una secuencia o la aplicación gradual de un filtro a lo largo del 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 charla de Guy Somberg sobre la programación de audio de juegos para inspirarte. (También hay una 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 tu juego y publícalo.