Adicionar som e música ao seu jogo do Flutter

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, com elementos visuais que mostram uma boa interface. Só falta o áudio. Neste codelab, você vai aprender a usar o plug-in flutter_soloud para usar sons e músicas de baixa latência no seu projeto. Comece com um scaffolding básico para poder ir direto às partes interessantes.

Ilustração de fones de ouvido desenhada à mão.

Você pode usar o que aprender aqui para adicionar áudio aos seus apps, não apenas aos jogos. 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

  • Ter noções básicas sobre o Flutter.
  • Saber 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 aumentar e diminuir os sons gradualmente.
  • 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

  1. 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.
  1. Crie um projeto do Flutter com o nome que quiser.
  1. Crie um arquivo lib/audio/audio_controller.dart no projeto.
  2. 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 isso durante este codelab.

  1. Em seguida, abra o arquivo lib/main.dart e substitua o conteúdo dele 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();
                    }
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
  1. Depois de fazer o download dos arquivos de áudio, crie um diretório na raiz do projeto com o nome assets.
  2. No diretório assets, crie dois subdiretórios, um com o nome music e o outro com o nome sounds.
  3. Mova os arquivos transferidos por download para o projeto para que o arquivo de música esteja no arquivo assets/music/looped-song.ogg e os sons de banco estejam nos seguintes arquivos:
  • assets/sounds/pew1.mp3
  • assets/sounds/pew2.mp3
  • assets/sounds/pew3.mp3

A estrutura do projeto vai ficar assim:

Uma visualização em árvore do projeto, com pastas como &quot;android&quot;, &quot;ios&quot;, arquivos como &quot;README.md&quot; e &quot;analysis_options.yaml&quot;. Dentre eles, podemos ver o diretório `assets` com os subdiretórios `music` e `Sounds`, o diretório `lib` com `main.dart` e um subdiretório `audio` com `audio_controller.dart`, e o arquivo `pubspec.yaml`.  As setas apontam para os novos diretórios e os arquivos que você editou até o momento.

Agora que os arquivos estão lá, você precisa informar ao Flutter sobre eles.

  1. Abra o arquivo pubspec.yaml e substitua a seção flutter: na parte de baixo do arquivo pelo seguinte:

pubspec.yaml

...

flutter:
  uses-material-design: true

  assets:
    - assets/music/
    - assets/sounds/
  1. Adicione uma dependência aos pacotes flutter_soloud e logging.

pubspec.yaml

...

dependencies:
  flutter:
    sdk: flutter

  flutter_soloud: ^2.0.0
  logging: ^1.2.0

...
  1. Executar o projeto. Nada funciona ainda porque você adicionou a funcionalidade nas seções a seguir.

10f0f751c9c47038.png

/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 encerrar

Para tocar áudio, use o plug-in flutter_soloud. Esse plug-in é baseado no projeto SoLoud, um mecanismo de áudio em C++ para jogos usados (entre outros) da Nintendo SNES Classic.

7ce23849b6d0d09a.png

Para inicializar o mecanismo de áudio SoLoud, siga estas etapas:

  1. No arquivo audio_controller.dart, importe o pacote flutter_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.

  1. 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 de singleton. Não é possível instanciar várias instâncias do SoLoud. Isso não é permitido pelo mecanismo C++, portanto, 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 facilitar, você não está detectando erros em um bloco try/catch. No código de produção, faça isso e informe os erros ao usuário.
  1. No método dispose(), insira este código:

lib/audio/audio_controller.dart

...

  void dispose() {
    _soloud?.deinit();
  }

...

Desligar o SoLoud na saída do app é uma prática recomendada, mas tudo deve funcionar bem, mesmo que você deixe de fazer isso.

  1. O método AudioController.initialize() já é chamado na função main(). Isso significa que a reinicialização a quente do projeto inicializa o SoLoud em segundo plano, mas isso não adianta nada antes de tocar alguns sons.

4. Tocar sons one-shot

Carregar e reproduzir um recurso

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 tocados. 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ê toca essa fonte de áudio, o SoLoud cria uma "instância de som" que é representado pelo tipo SoundHandle.

Você recebe uma instância de AudioSource ao carregá-la. 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 muitas vezes, até mesmo simultaneamente.

Ao terminar de usar uma fonte de áudio, ela deve ser descartada com o método SoLoud.disposeSource().

Para carregar e reproduzir um recurso, siga estas etapas:

  1. No método playSound() da classe AudioController, 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);
  }

  ...
  1. Salve o arquivo, faça a recarga automática e selecione Tocar som. Você vai ouvir um som engraçado de banco. Observe o seguinte:
  • O argumento assetKey fornecido é semelhante a assets/sounds/pew1.mp3, a mesma string que você forneceria para qualquer outra API do Flutter de carregamento de recursos, como o widget Image.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 classe AudioSource. Existem métodos equivalentes para carregar um arquivo do sistema de arquivos (o método loadFile()) e para carregar pela rede a partir de um URL (o método loadUrl()).
  • A instância AudioSource recém-adquirida é transmitida ao método play() do SoLoud. Esse método retorna uma instância do tipo SoundHandle que representa o som recém-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 pacote flutter_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, característica da maioria dos plug-ins do Flutter, não está em lugar nenhum. O único motivo pelo qual alguns métodos são 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 resumir. O código de produção precisa lidar com a situação em que o desenvolvedor tenta tocar um som antes que o controlador de áudio seja totalmente inicializado.

Lidar com exceções

Talvez você tenha percebido que está ignorando, mais uma vez, possíveis exceções. Vamos corrigir isso para esse 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, una as duas linhas do método playSound() em um bloco try/catch e capture apenas instâncias de SoLoudException.

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. Os documentos da API de cada método listam os tipos de exceção que podem ser gerados.

O SoLoud também fornece uma classe mãe para todas as exceções, a exceção SoLoudException, para que você possa capturar 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 causar falhas na sessão do jogo apenas porque um dos sons dos bancos não foi carregado.

Como esperado, 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ó toca o arquivo pew1.mp3, mas há outras duas versões do som no diretório de assets. 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.

Uma ilustração de

5. Reproduzir loops de música

Gerenciar sons que ficam em exibição por mais tempo

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 estalo de metal centenário ou a tosse distante dos pacientes.

Estas 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. Muitas vezes, eles também usam arquivos grandes e podem consumir muita memória. Então, outro motivo para rastreá-los é 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 música

Basicamente, tocar música não é diferente de tocar um som único. Primeiro, você ainda precisa carregar o arquivo assets/music/looped-song.ogg como uma instância da classe AudioSource e usar o método play() do SoLoud para reproduzi-lo.

Desta vez, você precisa segurar o identificador de som que o método play() retorna para manipular o áudio durante a reprodução.

  • Se quiser, implemente o método AudioController.startMusic() por conta própria. Tudo bem se você não acertar alguns dos 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);
  }

...

Você carrega o arquivo de música no modo de disco (a enumeração LoadMode.disk). Isso significa simplesmente que o arquivo só é carregado em partes conforme necessário. Para áudios de longa duração, geralmente é melhor carregar no modo de disco. Para efeitos sonoros curtos, faz mais sentido carregar e descompactar os efeitos 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.

88d2c57fffdfe996.png

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ê tem uma faixa de música que é um loop perfeito, sem nenhuma 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étodo startMusic() 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 dá muito trabalho.

Felizmente, o SoLoud oferece métodos simples de "disparar e esquecer" que fazem isso por você. Saiba como fazer a música esmaecer ao longo de cinco segundos e, em seguida, parar 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 processamento de áudio, como encaminhar alguns sons por meio de reverberação, equalizador ou filtro de passagem de baixa.

Em jogos, isso pode ser usado para diferenciação auditiva de locais. Por exemplo, o som de palmas é diferente na floresta do que em um bunker de concreto. Enquanto a floresta ajuda a dissipar e absorver o som, as paredes nuas de um bunker refletem as ondas sonoras de volta, levando à reverberação. Da mesma forma, as vozes das pessoas soam diferentes quando ouvidas em uma parede. As frequências mais altas desses sons são mais facilmente atenuadas à medida que viajam pelo meio sólido, resultando em um efeito de filtro passa-baixo.

Ilustração de duas pessoas conversando em uma sala. As ondas sonoras não só vão de uma pessoa diretamente para a outra, como também rebatem nas paredes e no teto.

O SoLoud oferece vários efeitos 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 tipo enumerado FilterType.freeverbFilter:

lib/audio/audio_controller.dart

...

  void applyFilter() {
    _soloud!.addGlobalFilter(FilterType.freeverbFilter);
    _soloud!.setFilterParameter(FilterType.freeverbFilter, 0, 0.2);
    _soloud!.setFilterParameter(FilterType.freeverbFilter, 2, 0.9);
  }

  void removeFilter() {
    _soloud!.removeGlobalFilter(FilterType.freeverbFilter);
  }

...

Como você pode ver, com os filtros, você mergulha em um território de nível mais baixo. A definição de um parâmetro de filtro é feita com o índice do parâmetro. Por exemplo, o parâmetro Wet do freeverb tem o índice 0, e o parâmetro "Tamanho do Room" tem o índice 2.

Com o código anterior, faça o seguinte:

  • Ative o filtro de verbo livre globalmente ou para toda a mixagem de áudio, não apenas para um único som.
  • 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 como 1.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 Tamanho do ambiente como 0.9. É possível ajustar esse parâmetro como quiser ou até mesmo alterá-lo de maneira dinâmica. 1.0 é uma caverna enorme, e 0.0 é um banheiro.
  • Se quiser, altere o código e aplique um dos filtros a seguir ou uma combinação dos seguintes filtros:
  • FilterType.biquadResonantFilter (pode ser usado como um filtro de passagem de baixas frequências)
  • FilterType.eqFilter
  • FilterType.echoFilter
  • FilterType.lofiFilter
  • FilterType.flangerFilter
  • FilterType.bassboostFilter
  • FilterType.waveShaperFilter
  • FilterType.robotizeFilter
  • FilterType.freeverbFilter

7. Parabéns

Você implementou um controlador de áudio que toca sons, repete músicas e aplica efeitos.

Saiba mais

  • Tente aprimorar o controlador de áudio com recursos como pré-carregamento de sons na inicialização, reprodução de músicas em sequência ou aplicação de 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 interagir 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 está se referindo a bibliotecas como SoLoud e FMOD. O restante do código tende a ser específico para cada jogo.
  • Crie e lance seu jogo.

Uma ilustração de fones de ouvido