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

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

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

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

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

Предварительные условия

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

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

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

Что вам нужно

  • Флаттер SDK
  • Редактор кода на ваш выбор

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 и замените его содержимое следующим кодом:

библиотека/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. После загрузки аудиофайлов создайте в корне вашего проекта каталог с именем 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 .

pubspec.yaml

...

dependencies:
  flutter:
    sdk: flutter

  flutter_soloud: ^2.0.0
  logging: ^1.2.0

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

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];

Они взяты из базовой библиотеки SoLoud C++. Они не влияют на функциональность и их можно смело игнорировать.

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

  ...

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

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

lib/audio/audio_controller.dart

...

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

...

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

  • SoLoud предоставляет одноэлементное поле instance . Невозможно создать несколько экземпляров 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. Сохраните файл, перезагрузите его и выберите «Воспроизвести звук» . Вы должны услышать глупый звук скамейки. Обратите внимание на следующее:
  • Предоставленный аргумент 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 не равно нулю с _soloud! . Это опять же для краткости. Производственный код должен корректно обрабатывать ситуацию, когда разработчик пытается воспроизвести звук до того, как аудиоконтроллер сможет полностью инициализироваться.

Работайте с исключениями

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

  • Чтобы справиться с исключениями в этом случае, оберните две строки метода 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 , но в каталоге ресурсов есть еще две версии звука. Часто звучит более естественно, когда в играх есть несколько версий одного и того же звука и разные версии воспроизводятся случайным образом или поочередно. Это предотвращает, например, то, что шаги и выстрелы звучат слишком однообразно и, следовательно, фальшиво.

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

Иллюстрация

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

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

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

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

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

lib/audio/audio_controller.dart

...

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

  SoLoud? _soloud;

  SoundHandle? _musicHandle;    // ← Add this.

  ...

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

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

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

  • Если хотите, реализуйте метод AudioController.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!);
      }
    }
    final musicSource = await _soloud!
        .loadAsset('assets/music/looped-song.ogg', mode: LoadMode.disk);
    _musicHandle = await _soloud!.play(musicSource);
  }

...

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

Однако у вас есть пара проблем. Во-первых, музыка слишком громкая, заглушающая звуки. В большинстве игр музыка большую часть времени находится на заднем плане, отдавая центральное место более информативному звуку, например речи и звуковым эффектам. Это легко исправить, используя параметр громкости метода play. Например, вы можете попробовать _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 предоставляет несколько различных звуковых эффектов, которые вы можете применить к звуку.

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

...

Как видите, с фильтрами вы углубляетесь на более низкоуровневую территорию. Установка параметра фильтра осуществляется с помощью индекса параметра. Например, параметр Wet Freeverb имеет индекс 0 , а параметр Room Size — индекс 2 .

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

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

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

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

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

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

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