Добавьте звук и музыку в свою игру Flutter

1. Прежде чем начать

Игры — это аудиовизуальные впечатления. Flutter — отличный инструмент для создания красивых визуальных эффектов и надежного пользовательского интерфейса, поэтому он позволяет вам далеко продвинуться в визуальной части вещей. Недостающий ингредиент, который остался, — это аудио. В этой кодовой лаборатории вы узнаете, как использовать плагин flutter_soloud для внедрения звука и музыки с низкой задержкой в ​​ваш проект. Вы начинаете с базового каркаса, чтобы можно было сразу перейти к интересным частям.

Нарисованная от руки иллюстрация наушников.

Конечно, вы можете использовать то, чему здесь научитесь, чтобы добавлять аудио в свои приложения , а не только в игры. Но хотя почти все игры требуют звука и музыки, большинству приложений это не нужно, поэтому эта кодовая лаборатория фокусируется на играх.

Предпосылки

  • Базовые знания Flutter.
  • Знание того, как запускать и отлаживать приложения Flutter.

Что вы узнаете

  • Как воспроизводить одноразовые звуки.
  • Как воспроизводить и настраивать непрерывные музыкальные циклы.
  • Как усиливать и ослаблять звуки.
  • Как применять эффекты окружающей среды к звукам.
  • Как работать с исключениями.
  • Как объединить все эти функции в одном аудиоконтроллере.

Что вам нужно

  • Пакет SDK Flutter
  • Редактор кода по вашему выбору

2. Настройка

  1. Загрузите следующие файлы. Если у вас медленное соединение, не волнуйтесь. Сами файлы вам понадобятся позже, поэтому вы можете позволить им загрузиться, пока вы работаете.
  1. Создайте проект Flutter с именем по вашему выбору.
  1. Создайте в проекте файл lib/audio/audio_controller.dart .
  2. В файле введите следующий код:

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
  }
}

Как вы видите, это только скелет будущей функциональности. Мы реализуем все это в ходе этой кодовой лаборатории.

  1. Затем откройте файл lib/main.dart и замените его содержимое следующим кодом:

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();
                    }
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
  1. После загрузки аудиофайлов создайте в корне проекта каталог с именем assets .
  2. В каталоге assets создайте два подкаталога: один с именем music , а другой с именем sounds .
  3. Переместите загруженные файлы в свой проект так, чтобы файл песни находился в файле assets/music/looped-song.ogg , а звуки церковной скамьи — в следующих файлах:
  • assets/sounds/pew1.mp3
  • assets/sounds/pew2.mp3
  • assets/sounds/pew3.mp3

Структура вашего проекта теперь должна выглядеть примерно так:

Древовидное представление проекта с папками, такими как `android`, `ios`, файлами, такими как `README.md` и `analysis_options.yaml`. Среди них мы видим каталог `assets` с подкаталогами `music` и `sounds`, каталог `lib` с `main.dart` и подкаталог `audio` с `audio_controller.dart`, а также файл `pubspec.yaml`. Стрелки указывают на новые каталоги и файлы, которые вы уже коснулись.

Теперь, когда файлы есть, вам нужно сообщить о них Flutter.

  1. Откройте файл pubspec.yaml и замените раздел flutter: в нижней части файла следующим:

pubspec.yaml

...

flutter:
  uses-material-design: true

  assets:
    - assets/music/
    - assets/sounds/
  1. Добавьте зависимость от пакета flutter_soloud и пакета logging .
flutter pub add flutter_soloud logging

Теперь ваш файл pubspec.yaml должен иметь дополнительные зависимости от пакетов flutter_soloud и logging .

pubspec.yaml

...

dependencies:
  flutter:
    sdk: flutter

  flutter_soloud: ^3.1.10
  logging: ^1.3.0

...
  1. Запустите проект. Пока ничего не работает, потому что вы добавляете функционал в следующих разделах.

10f0f751c9c47038.png

3. Инициализация и выключение

Для воспроизведения звука используется плагин flutter_soloud . Этот плагин основан на проекте SoLoud , звуковом движке C++ для игр, который используется, среди прочего, Nintendo SNES Classic.

7ce23849b6d0d09a.png

Чтобы инициализировать звуковой движок SoLoud, выполните следующие действия:

  1. В файле audio_controller.dart импортируйте пакет flutter_soloud и добавьте в класс закрытое поле _soloud .

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
  }

  ...

Аудиоконтроллер управляет базовым движком SoLoud через это поле и перенаправляет все вызовы на него.

  1. В методе initialize() введите следующий код:

lib/audio/audio_controller.dart

...

  Future<void> initialize() async {
    _soloud = SoLoud.instance;
    await _soloud!.init();
  }

...

Это заполняет поле _soloud и ждет инициализации. Обратите внимание на следующее:

  • SoLoud предоставляет поле instance singleton. Невозможно создать несколько экземпляров SoLoud. Это не то, что позволяет движок C++, поэтому это не допускается и плагином Dart.
  • Инициализация плагина асинхронна и не завершается до тех пор, пока не будет выполнен метод init() .
  • Для краткости в этом примере вы не перехватываете ошибки в блоке try/catch . В производственном коде вы хотите это сделать и сообщить о любых ошибках пользователю.
  1. В методе dispose() введите следующий код:

lib/audio/audio_controller.dart

...

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

...

Хорошей практикой является отключение SoLoud при выходе из приложения, хотя все должно работать нормально, даже если вы этого не сделаете.

  1. Обратите внимание, что метод AudioController.initialize() уже вызывается из функции main() . Это означает, что горячий перезапуск проекта инициализирует SoLoud в фоновом режиме, но это не принесет вам никакой пользы, пока вы не воспроизведете какие-нибудь звуки.

4. Воспроизведение одноразовых звуков

Загрузите актив и воспроизведите его

Теперь, когда вы знаете, что SoLoud инициализируется при запуске, вы можете попросить его воспроизвести звуки.

SoLoud различает источник звука, который представляет собой данные и метаданные, используемые для описания звука, и его «экземпляры звука», которые представляют собой фактически воспроизводимые звуки. Примером источника звука может быть файл mp3, загруженный в память, готовый к воспроизведению и представленный экземпляром класса AudioSource . Каждый раз, когда вы воспроизводите этот источник звука, SoLoud создает «экземпляр звука», представленный типом SoundHandle .

Вы получаете экземпляр AudioSource , загружая его. Например, если у вас есть файл mp3 в ваших активах, вы можете загрузить его, чтобы получить AudioSource . Затем вы говорите SoLoud воспроизвести этот AudioSource . Вы можете воспроизвести его много раз, даже одновременно.

Когда работа с источником звука закончена, вы удаляете его с помощью метода SoLoud.disposeSource() .

Чтобы загрузить ресурс и воспроизвести его, выполните следующие действия:

  1. В методе playSound() класса AudioController введите следующий код:

lib/audio/audio_controller.dart

  ...

  Future<void> playSound(String assetKey) async {
    final source = await _soloud!.loadAsset(assetKey);
    await _soloud!.play(source);
  }

  ...
  1. Сохраните файл, выполните горячую перезагрузку, а затем выберите Play sound . Вы должны услышать глупый звук скамьи. Обратите внимание на следующее:
  • Предоставленный аргумент assetKey — это что-то вроде assets/sounds/pew1.mp3 — та же самая строка, которую вы бы предоставили любому другому API Flutter для загрузки ресурсов, например виджету Image.asset() .
  • Экземпляр SoLoud предоставляет метод loadAsset() , который асинхронно загружает аудиофайл из ресурсов проекта Flutter и возвращает экземпляр класса AudioSource . Существуют эквивалентные методы для загрузки файла из файловой системы (метод loadFile() ) и для загрузки по сети с URL (метод loadUrl() ).
  • Затем полученный экземпляр AudioSource передается методу play() SoLoud. Этот метод возвращает экземпляр типа SoundHandle , представляющий новый воспроизводимый звук. Этот дескриптор, в свою очередь, может быть передан другим методам SoLoud для выполнения таких действий, как приостановка, остановка или изменение громкости звука.
  • Хотя play() является асинхронным методом, воспроизведение начинается практически мгновенно. Пакет flutter_soloud использует интерфейс внешних функций Dart (FFI) для прямого и синхронного вызова кода C. Обычный обмен сообщениями между кодом Dart и кодом платформы, характерный для большинства плагинов Flutter, нигде не встречается. Единственная причина, по которой некоторые методы являются асинхронными, заключается в том, что часть кода плагина выполняется в его собственном изоляте, а связь между изолятами Dart является асинхронной.
  • Вы утверждаете, что поле _soloud не равно null с помощью _soloud! . Это, опять же, для краткости. Производственный код должен изящно справляться с ситуацией, когда разработчик пытается воспроизвести звук до того, как аудиоконтроллер получит возможность полностью инициализироваться.

Иметь дело с исключениями

Вы могли заметить, что вы снова игнорируете возможные исключения. Пришло время исправить это для этого конкретного метода в учебных целях. (Для краткости после этого раздела codelab возвращается к игнорированию исключений.)

  • Чтобы справиться с исключениями в этом случае, заключите две строки метода playSound() в блок try/catch и перехватывайте только экземпляры 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);
    }
  }

  ...

SoLoud выдает различные исключения, такие как SoLoudNotInitializedException или SoLoudTemporaryFolderFailedException . В документации API каждого метода перечислены виды исключений, которые могут быть выданы.

SoLoud также предоставляет родительский класс для всех своих исключений, исключение SoLoudException , чтобы вы могли перехватывать все ошибки, связанные с функциональностью аудиодвижка. Это особенно полезно в случаях, когда воспроизведение звука не является критически важным. Например, когда вы не хотите, чтобы игровой сеанс игрока был прерван только из-за того, что один из звуков пиу-пиу не смог загрузиться.

Как вы, вероятно, ожидаете, метод loadAsset() также может выдать ошибку FlutterError , если вы предоставите ключ ассета, который не существует. Попытка загрузить ассеты, которые не связаны с игрой, как правило, является тем, на что следует обратить внимание, поэтому это ошибка .

Воспроизведение разных звуков

Вы могли заметить, что вы воспроизводите только файл pew1.mp3 , но в каталоге assets есть еще две версии звука. Часто он звучит более естественно, когда в играх есть несколько версий одного и того же звука, и разные версии воспроизводятся случайным образом или по очереди. Это предотвращает, например, слишком единообразное звучание шагов и выстрелов, а значит, и фальшивое.

  • В качестве дополнительного упражнения измените код так, чтобы он воспроизводил другой звук скамьи при каждом нажатии кнопки.

Иллюстрация

5. Воспроизведение музыкальных циклов

Управление продолжительными звуками

Некоторые аудиозаписи предназначены для воспроизведения в течение длительного времени. Музыка — очевидный пример, но многие игры также воспроизводят окружение, например, завывание ветра в коридорах, отдаленное пение монахов, скрип многовекового металла или отдаленный кашель пациентов.

Это аудиоисточники с временем воспроизведения, которое можно измерить в минутах. Вам нужно отслеживать их, чтобы иметь возможность приостановить или остановить их, когда это необходимо. Они также часто поддерживаются большими файлами и могут занимать много памяти, поэтому еще одна причина отслеживать их — это возможность избавиться от экземпляра AudioSource , когда он больше не нужен.

По этой причине вы введете новое частное поле в AudioController . Это дескриптор для воспроизводимой песни, если таковая имеется. Добавьте следующую строку:

lib/audio/audio_controller.dart

...

class AudioController {
  static final Logger _log = Logger('AudioController');

  SoLoud? _soloud;

  SoundHandle? _musicHandle;    // ← Add this.

  ...

Включить музыку

По сути, воспроизведение музыки ничем не отличается от воспроизведения звука one-shot. Вам все равно сначала нужно загрузить файл assets/music/looped-song.ogg как экземпляр класса AudioSource , а затем использовать метод play() SoLoud для его воспроизведения.

Однако на этот раз вы берете на себя управление звуком, которое возвращает метод play() чтобы управлять звуком во время его воспроизведения.

  • Если хотите, реализуйте метод AudioController.startMusic() самостоятельно. Ничего страшного, если вы не сделаете некоторые детали правильно. Важно, чтобы музыка начиналась, когда вы выбираете Start music .

Вот пример реализации:

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,
    );
  }

...

Обратите внимание, что вы загружаете музыкальный файл в дисковом режиме (перечисление LoadMode.disk ). Это означает, что файл загружается только частями по мере необходимости. Для более продолжительного аудио обычно лучше загружать в дисковом режиме. Для коротких звуковых эффектов имеет смысл загружать и распаковывать их в память (перечисление LoadMode.memory по умолчанию).

Однако у вас есть пара проблем. Во-первых, музыка слишком громкая, перекрывающая звуки. В большинстве игр музыка большую часть времени играет на заднем плане, уступая центральное место более информативному звуку, такому как речь и звуковые эффекты. Это можно исправить с помощью параметра громкости метода воспроизведения. Например, вы можете попробовать _soloud!.play(musicSource, volume: 0.6) для воспроизведения песни на громкости 60%. В качестве альтернативы вы можете установить громкость в любой момент позже с помощью чего-то вроде _soloud!.setVolume(_musicHandle, 0.6) .

Вторая проблема заключается в том, что песня резко обрывается. Это происходит потому, что эта песня должна воспроизводиться циклично, а начальная точка цикла не совпадает с началом аудиофайла.

88d2c57fffdfe996.png

Это популярный выбор для игровой музыки, потому что это означает, что песня начинается с естественного вступления и затем играет столько, сколько нужно, без очевидной точки цикла. Когда игре нужно перейти от звучащей песни, она затухает песню.

К счастью, SoLoud предоставляет способы воспроизведения зацикленного аудио. Метод play() принимает логическое значение для параметра looping , а также значение для начальной точки цикла в качестве параметра loopingStartAt . Результирующий код выглядит следующим образом:

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),
);

...

Если вы не зададите параметр loopingStartAt , по умолчанию он будет равен Duration.zero (другими словами, начало аудиофайла). Если у вас есть музыкальный трек, который представляет собой идеальный цикл без какого-либо вступления, это то, что вам нужно.

  • Чтобы убедиться, что источник звука правильно удален после завершения воспроизведения, послушайте поток allInstancesFinished , который предоставляет каждый источник звука. С добавленными вызовами журнала метод startMusic() выглядит следующим образом:

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),
    );
  }

...

Затухание звука

Ваша следующая проблема в том, что музыка никогда не заканчивается. Пришло время реализовать затухание.

Один из способов реализовать затухание — иметь какую-то функцию, которая вызывается несколько раз в секунду, например, Ticker или Timer.periodic , и уменьшать громкость музыки небольшими декрементами. Это сработает, но это требует много работы.

К счастью, SoLoud предоставляет удобные методы «запустить и забыть», которые делают это за вас. Вот как можно затухать музыку в течение пяти секунд, а затем остановить экземпляр звука, чтобы он не потреблял ресурсы ЦП без необходимости. Замените метод fadeOutMusic() на этот код:

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. Применить эффекты

Огромным преимуществом наличия в вашем распоряжении полноценного звукового движка является то, что вы можете выполнять обработку звука, например, пропускать некоторые звуки через ревербератор, эквалайзер или фильтр нижних частот.

В играх это можно использовать для слуховой дифференциации локаций. Например, хлопок звучит по-разному в лесу и в бетонном бункере. В то время как лес помогает рассеивать и поглощать звук, голые стены бункера отражают звуковые волны обратно, что приводит к реверберации. Аналогично, голоса людей звучат по-разному, если их слушать через стену. Более высокие частоты этих звуков сильнее ослабляются при прохождении через твердую среду, что приводит к эффекту фильтра нижних частот.

Иллюстрация разговора двух людей в комнате. Звуковые волны не только передаются от одного человека к другому напрямую, но и отражаются от стен и потолка.

SoLoud предоставляет несколько различных аудиоэффектов, которые можно применять к аудио.

  • Чтобы создать впечатление, будто ваш плеер находится в большой комнате, например, в соборе или пещере, используйте поле 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();
  }

...

Поле SoLoud.filters дает вам доступ ко всем типам фильтров и их параметрам. Каждый параметр также имеет встроенные функции, такие как постепенное затухание и осцилляция.

Примечание: _soloud!.filters раскрывает глобальные фильтры. Если вы хотите применить фильтры к одному источнику, используйте аналог AudioSource.filters , который действует так же.

С помощью предыдущего кода вы делаете следующее:

  • Включить фильтр свободных глаголов глобально.
  • Установите параметр Wet на 0.2 , что означает, что полученный звук будет на 80% оригинальным и на 20% выходным сигналом эффекта реверберации. Если вы установите этот параметр на 1.0 , это будет похоже на то, что вы слышите только звуковые волны, которые возвращаются к вам от далеких стен комнаты, и никакого исходного звука.
  • Установите параметр « Размер комнаты» на 0.9 . Вы можете настроить этот параметр по своему вкусу или даже изменить его динамически. 1.0 — это огромная пещера, а 0.0 — это ванная комната.
  • Если вы готовы, измените код и примените один из следующих фильтров или комбинацию следующих фильтров:
  • biquadFilter (может использоваться как фильтр нижних частот)
  • pitchShiftFilter
  • equalizerFilter
  • echoFilter
  • lofiFilter
  • flangerFilter
  • bassboostFilter
  • waveShaperFilter
  • robotizeFilter

7. Поздравления

Вы реализовали аудиоконтроллер, который воспроизводит звуки, зацикливает музыку и применяет эффекты.

Узнать больше

  • Попробуйте расширить возможности аудиоконтроллера, добавив такие функции, как предварительная загрузка звуков при запуске, последовательное воспроизведение песен или постепенное применение фильтра с течением времени.
  • Прочитайте документацию пакета flutter_soloud .
  • Ознакомьтесь с домашней страницей базовой библиотеки C++.
  • Узнайте больше о Dart FFI — технологии, используемой для взаимодействия с библиотекой C++.
  • Посмотрите выступление Гая Сомберга о программировании игрового звука для вдохновения. (Есть и более длинное выступление .) Когда Гай говорит о «промежуточном программном обеспечении», он имеет в виду библиотеки вроде SoLoud и FMOD. Остальной код, как правило, специфичен для каждой игры.
  • Создайте свою игру и выпустите ее.

Иллюстрация наушников.