1. Antes de começar
Os jogos são experiências audiovisuais. O Flutter é uma ótima ferramenta para criar belos recursos visuais e uma interface sólida, oferecendo uma visão melhor do que é realmente importante. Só falta o áudio. Neste codelab, você vai aprender a usar o plug-in flutter_soloud
para introduzir som e música de baixa latência no seu projeto. Comece com um scaffolding básico para poder ir direto às partes interessantes.
Você pode usar o que aprendeu aqui para adicionar áudio aos seus apps, não apenas aos jogos. No entanto, quase todos os jogos exigem som e música, mas a maioria dos apps não. Por isso, este codelab se concentra em jogos.
Pré-requisitos
- Noções básicas do Flutter.
- Conhecimento de como executar e depurar apps do Flutter.
Conteúdo do laboratório
- Como reproduzir sons one-shot.
- Como tocar e personalizar loops de música sem lacunas.
- Como fazer sons aparecerem e desaparecerem.
- Como aplicar efeitos de ambiente aos sons.
- Como lidar com exceções.
- Como encapsular todos esses recursos em um único controlador de áudio.
O que é necessário
- O SDK do Flutter
- Um editor de código de sua escolha
2. Configurar
- Faça o download dos arquivos a seguir. Se sua conexão for lenta, não se preocupe. Você precisa dos arquivos reais mais tarde para poder permitir o download deles enquanto trabalha.
- Crie um projeto do Flutter com o nome que você quiser.
- Crie um arquivo
lib/audio/audio_controller.dart
no projeto. - Insira este código no arquivo:
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 você pode ver, isso é apenas um esqueleto para funcionalidades futuras. Vamos implementar tudo durante este codelab.
- Em seguida, abra o arquivo
lib/main.dart
e substitua o conteúdo por este 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();
}
},
),
],
),
],
),
),
);
}
}
- Depois de fazer o download dos arquivos de áudio, crie um diretório na raiz do projeto com o nome
assets
. - No diretório
assets
, crie dois subdiretórios, um chamadomusic
e outrosounds
. - Mova os arquivos transferidos para o projeto para que o arquivo da música esteja no arquivo
assets/music/looped-song.ogg
e os sons de bancos estejam nos seguintes arquivos:
assets/sounds/pew1.mp3
assets/sounds/pew2.mp3
assets/sounds/pew3.mp3
A estrutura do projeto vai ficar assim:
Agora que os arquivos estão lá, você precisa informar ao Flutter sobre eles.
- Abra o arquivo
pubspec.yaml
e substitua a seçãoflutter:
na parte de baixo do arquivo pelo seguinte:
pubspec.yaml
...
flutter:
uses-material-design: true
assets:
- assets/music/
- assets/sounds/
- Adicione uma dependência aos pacotes
flutter_soloud
elogging
.
pubspec.yaml
...
dependencies:
flutter:
sdk: flutter
flutter_soloud: ^2.0.0
logging: ^1.2.0
...
- Executar o projeto. Nada funciona ainda porque você vai adicionar a funcionalidade nas próximas seções.
/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];
Eles vêm da biblioteca C++ SoLoud
. Elas não afetam a funcionalidade e podem ser ignoradas com segurança.
3. Inicializar e desligar
Para tocar áudio, use o plug-in flutter_soloud
. Esse plug-in é baseado no projeto SoLoud (link em inglês), um mecanismo de áudio C++ para jogos usado, entre outros, pelo Nintendo SNES Classic.
Para inicializar o mecanismo de áudio SoLoud, siga estas etapas:
- No arquivo
audio_controller.dart
, importe o pacoteflutter_soloud
e adicione um campo_soloud
particular à 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
}
...
O controlador de áudio gerencia o mecanismo SoLoud subjacente por meio desse campo e encaminhará todas as chamadas para ele.
- No método
initialize()
, insira este código:
lib/audio/audio_controller.dart
...
Future<void> initialize() async {
_soloud = SoLoud.instance;
await _soloud!.init();
}
...
Isso preenche o campo _soloud
e aguarda a inicialização. Observe o seguinte:
- O SoLoud fornece um campo
instance
único. Não é possível instanciar várias instâncias do SoLoud. Isso não é algo permitido pelo mecanismo C++, então também não é permitido pelo plug-in Dart. - A inicialização do plug-in é assíncrona e não é concluída até que o método
init()
seja retornado. - Para encurtar este exemplo, não vamos capturar erros em um bloco
try/catch
. No código de produção, você quer fazer isso e informar os erros ao usuário.
- No método
dispose()
, insira o seguinte código:
lib/audio/audio_controller.dart
...
void dispose() {
_soloud?.deinit();
}
...
É uma boa prática desativar o SoLoud ao sair do app, mas tudo deve funcionar bem mesmo se você não fizer isso.
- O método
AudioController.initialize()
já é chamado na funçãomain()
. Isso significa que a reinicialização a quente do projeto inicializa o SoLoud em segundo plano, mas não vai funcionar antes de você reproduzir alguns sons.
4. Tocar sons únicos
Carregar um recurso e reproduzi-lo
Agora que você sabe que o SoLoud foi inicializado na inicialização, pode pedir para ele tocar sons.
O SoLoud diferencia uma fonte de áudio, que são os dados e metadados usados para descrever um som, e as "instâncias de som", que são os sons realmente reproduzidos. Um exemplo de fonte de áudio pode ser um arquivo mp3 carregado na memória, pronto para ser reproduzido e representado por uma instância da classe AudioSource
. Sempre que você reproduz essa fonte de áudio, o SoLoud cria uma "instância de som", que é representada pelo tipo SoundHandle
.
Para acessar uma instância AudioSource
, carregue-a. Por exemplo, se você tiver um arquivo mp3 nos recursos, poderá carregá-lo para gerar um AudioSource
. Depois, você pede ao SoLoud para tocar esse AudioSource
. Você pode jogar várias vezes, até mesmo simultaneamente.
Ao terminar de usar uma fonte de áudio, ela deve ser descartada com o método SoLoud.disposeSource()
.
Para carregar um recurso e reproduzi-lo, siga estas etapas:
- No método
playSound()
da classeAudioController
, digite este código:
lib/audio/audio_controller.dart
...
Future<void> playSound(String assetKey) async {
final source = await _soloud!.loadAsset(assetKey);
await _soloud!.play(source);
}
...
- Salve o arquivo, faça o reload rápido e selecione Play sound. Você vai ouvir um som de banco de igreja. Observe o seguinte:
- O argumento
assetKey
fornecido é semelhante aassets/sounds/pew1.mp3
, a mesma string que você forneceria para qualquer outra API de carregamento de recursos do Flutter, como o widgetImage.asset()
. - A instância do SoLoud oferece um método
loadAsset()
que carrega de forma assíncrona um arquivo de áudio dos recursos do projeto Flutter e retorna uma instância da classeAudioSource
. Há métodos equivalentes para carregar um arquivo do sistema de arquivos (o métodoloadFile()
) e para carregar pela rede de um URL (o métodoloadUrl()
). - A instância
AudioSource
recém-adquirida é transmitida ao métodoplay()
do SoLoud. Esse método retorna uma instância do tipoSoundHandle
que representa o som que está sendo reproduzido. Esse identificador pode ser transmitido para outros métodos do SoLoud que realizam ações como pausar, parar ou modificar o volume do som. - Embora
play()
seja um método assíncrono, a reprodução é iniciada basicamente instantaneamente. O pacoteflutter_soloud
usa a interface de função externa (FFI, na sigla em inglês) do Dart para chamar o código C de maneira direta e síncrona. A troca de mensagens entre o código Dart e o código da plataforma, que é característica da maioria dos plug-ins do Flutter, não é encontrada. O único motivo para alguns métodos serem assíncronos é que parte do código do plug-in é executado no próprio isolamento, e a comunicação entre os isolamentos do Dart é assíncrona. - Basta declarar que o campo
_soloud
não é nulo com_soloud!
. Isso é, novamente, para agilizar. O código de produção precisa lidar com a situação em que o desenvolvedor tenta reproduzir um som antes que o controlador de áudio tenha a chance de inicializar totalmente.
Lidar com exceções
Você pode ter notado que está ignorando possíveis exceções. Vamos corrigir isso para este método específico para fins de aprendizado. Para facilitar, o codelab volta a ignorar exceções após esta seção.
- Para lidar com exceções nesse caso, envolva as duas linhas do método
playSound()
em um blocotry/catch
e capture apenas instâncias 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);
}
}
...
O SoLoud gera várias exceções, como SoLoudNotInitializedException
ou SoLoudTemporaryFolderFailedException
. As documentações da API de cada método listam os tipos de exceções que podem ser geradas.
O SoLoud também fornece uma classe mãe para todas as exceções, a exceção SoLoudException
, para que você possa detectar todos os erros relacionados à funcionalidade do mecanismo de áudio. Isso é especialmente útil nos casos em que a reprodução de áudio não é essencial. Por exemplo, quando você não quer que a sessão do jogador falhe apenas porque um dos sons não carregou.
Como você provavelmente espera, o método loadAsset()
também pode gerar um erro FlutterError
se você fornecer uma chave de recurso que não existe. Tentar carregar recursos que não estão incluídos no jogo geralmente é algo que você precisa resolver, então é um erro.
Tocar sons diferentes
Você pode ter notado que só é possível reproduzir o arquivo pew1.mp3
, mas há duas outras versões do som no diretório de recursos. Muitas vezes, soa mais natural quando os jogos têm várias versões do mesmo som e tocam as diferentes versões de maneira aleatória ou rotativa. Isso impede, por exemplo, que passos e tiros pareçam muito uniformes e, portanto, falsos.
- Como exercício opcional, modifique o código para tocar um som de banco diferente sempre que o botão for tocado.
5. Tocar loops de música
Gerenciar sons mais longos
Alguns áudios devem ser reproduzidos por longos períodos. A música é o exemplo óbvio, mas muitos jogos também têm um ambiente, como o vento uivando pelos corredores, o canto distante dos monges, o rangido de metal centenário ou a tosse distante dos pacientes.
São fontes de áudio com tempos de reprodução que podem ser medidos em minutos. Você precisa acompanhá-las para que possa pausá-las ou interrompê-las quando necessário. Elas também costumam ser apoiadas por arquivos grandes e podem consumir muita memória. Portanto, outra razão para rastreá-las é para que você possa descartar a instância AudioSource
quando ela não for mais necessária.
Por esse motivo, você introduzirá um novo campo particular no AudioController
. É um identificador da música que está tocando, se houver. Adicione a seguinte linha:
lib/audio/audio_controller.dart
...
class AudioController {
static final Logger _log = Logger('AudioController');
SoLoud? _soloud;
SoundHandle? _musicHandle; // ← Add this.
...
Iniciar a música
Basicamente, tocar música não é diferente de tocar um som único. Primeiro, você precisa carregar o arquivo assets/music/looped-song.ogg
como uma instância da classe AudioSource
e, em seguida, usar o método play()
do SoLoud para reproduzir.
Mas, dessa vez, você vai usar o identificador de som que o método play()
retorna para manipular o áudio enquanto ele está sendo reproduzido.
- Se quiser, implemente o método
AudioController.startMusic()
por conta própria. Não tem problema se você não entender alguns detalhes. O importante é que a música comece quando você selecionar Iniciar música.
Confira uma implementação de referência:
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);
}
...
Observe que você carrega o arquivo de música no modo de disco (o tipo enumerado LoadMode.disk
). Isso significa que o arquivo só é carregado em partes conforme necessário. Para áudios mais longos, geralmente é melhor carregar no modo de disco. Para efeitos sonoros curtos, faz mais sentido carregá-los e descompactá-los na memória (o tipo enumerado LoadMode.memory
padrão).
No entanto, você tem alguns problemas. Primeiro, a música é muito alta, intensificando os sons. Na maioria dos jogos, a música fica em segundo plano na maior parte do tempo, sendo o centro dos áudios mais informativos, como fala e efeitos sonoros. Isso é fácil de corrigir usando o parâmetro de volume do método de reprodução. Por exemplo, tente o _soloud!.play(musicSource, volume: 0.6)
para tocar a música em 60% do volume. Como alternativa, você pode definir o volume a qualquer momento com algo como _soloud!.setVolume(_musicHandle, 0.6)
.
O segundo problema é que a música para abruptamente. Isso ocorre porque a música precisa ser tocada em repetição, e o ponto de partida dessa repetição não é o começo do arquivo de áudio.
Essa é uma escolha comum para músicas de jogos, porque significa que elas começam com uma introdução natural e são tocadas pelo tempo necessário, sem um ponto de repetição óbvio. Quando o jogo precisar sair da música em reprodução, a música será esmaecida.
Felizmente, o SoLoud oferece maneiras de reproduzir áudio em loop. O método play()
usa um valor booleano para o parâmetro looping
e o valor do ponto de partida do loop como o parâmetro loopingStartAt
. O código resultante vai ficar assim:
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),
);
...
Se você não definir o parâmetro loopingStartAt
, o padrão será Duration.zero
(ou seja, o início do arquivo de áudio). Se você tiver uma faixa de música que seja um loop perfeito sem introdução, é isso que você quer.
- Para garantir que a fonte de áudio seja descartada corretamente quando terminar de tocar, ouça o stream
allInstancesFinished
que cada fonte de áudio oferece. Com chamadas de registro adicionadas, o métodostartMusic()
ficará assim:
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),
);
}
...
Som esmaecido
Seu próximo problema é que a música nunca acaba. Vamos implementar um esmaecimento.
Uma maneira de implementar o esmaecimento seria usar algum tipo de função chamada várias vezes por segundo, como Ticker
ou Timer.periodic
, e diminuir o volume da música em pequenas deduções. Isso funcionaria, mas é muito trabalho.
Felizmente, o SoLoud fornece métodos convenientes do tipo “disparar e esquecer”. Veja como você pode diminuir o volume da música ao longo de cinco segundos e, em seguida, interromper a instância de som para que ela não consuma recursos da CPU desnecessariamente. Substitua o 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. Aplicar efeitos
Uma grande vantagem de ter um mecanismo de áudio adequado à sua disposição é que você pode fazer o processamento de áudio, como encaminhar alguns sons por um reverb, um equalizador ou um filtro de passagem baixa.
Em jogos, isso pode ser usado para a diferenciação auditiva de locais. Por exemplo, um aplauso soa diferente em uma floresta do que em um bunker de concreto. Enquanto uma floresta ajuda a dissipar e absorver o som, as paredes nuas de um bunker refletem as ondas sonoras, causando reverberação. Da mesma forma, as vozes das pessoas soam diferentes quando ouvidas por uma parede. As frequências mais altas desses sons são atenuadas com mais facilidade à medida que viajam pelo meio sólido, resultando em um efeito de filtro passa-baixa.
O SoLoud oferece vários efeitos de áudio diferentes, que podem ser aplicados ao áudio.
- Para fazer parecer que o player está em uma sala grande, como uma catedral ou caverna, use o 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();
}
...
O campo SoLoud.filters
dá acesso a todos os tipos de filtro e os parâmetros deles. Cada parâmetro também tem funcionalidades integradas, como desbotamento e oscilação gradual.
Observação: _soloud!.filters
expõe filtros globais. Se você quiser aplicar filtros a uma única fonte, use a contraparte AudioSource.filters
, que funciona da mesma forma.
Com o código anterior, faça o seguinte:
- Ativar o filtro de verbo livre globalmente.
- Defina o parâmetro Wet como
0.2
, o que significa que o áudio resultante será 80% original e 20% da saída do efeito de reverberação. Se você definir esse parâmetro como1.0
, será como ouvir apenas as ondas sonoras que voltam para você das paredes distantes da sala, e nenhum do áudio original. - Defina o parâmetro Room Size como
0.9
. Você pode ajustar esse parâmetro de acordo com sua preferência ou até mesmo mudar de forma dinâmica.1.0
é uma caverna enorme, e0.0
é um banheiro.
- Se você quiser, mude o código e aplique um ou uma combinação dos seguintes filtros:
biquadFilter
(pode ser usado como um filtro de passagem de baixas frequências)pitchShiftFilter
equalizerFilter
echoFilter
lofiFilter
flangerFilter
bassboostFilter
waveShaperFilter
robotizeFilter
7. Parabéns
Você implementou um controlador de áudio que reproduz sons, faz loops de música e aplica efeitos.
Saiba mais
- Tente aproveitar ainda mais o controle de áudio com recursos como pré-carregar sons na inicialização, tocar músicas em sequência ou aplicar um filtro gradualmente ao longo do tempo.
- Leia a documentação do pacote (link em inglês) do
flutter_soloud
. - Leia a página inicial da biblioteca C++.
- Leia mais sobre a FFI do Dart, a tecnologia usada para fazer a interface com a biblioteca C++.
- Para se inspirar, assista à palestra de Guy Somberg (em inglês) sobre programação de áudio de jogos. Há também uma mais longa. Quando Guy fala sobre "middleware", ele se refere a bibliotecas como SoLoud e FMOD. O restante do código tende a ser específico para cada jogo.
- Crie e lance seu jogo.