Создание бит-машины во Flutter
Егор Долгов · 05.04.2021Эта статья о том, как построить бит-машину с секвенсором в Dart/Flutter.
Автор оригинального текста Ken Reilly. Оригинальная статья тут .
Пример приложения
Вступление
С первого релиза Flutter в 2018 году прошло не так много времени, однако он уже набрал довольно-таки большую популярность. Компании и предприниматели стараются не тратить много денег на разработку приложений, так что поиск наилучшего и наиболее эффективного пути для создания мобильных приложений и программного обеспечения в целом не прекращается. Flutter работает на всех основных мобильных платформах, а поддержка WEB и всех основных настольных операционных систем находятся в стадии активной разработки.
В этой статье мы рассмотрим довольно-таки простую демо-версию драм-машины и узнаем о базовых шаблонах, используемых при разработке такого приложения.
Чтобы получить и запустить окружение Flutter, посетите эту страницу загрузки . А чтобы получить копию исходного коды этого проекта, зайдите на этот репозиторий .
Ключевые понятия
В создании этого демо-приложения мы будем использовать несколько ключевых понятий Flutter для использования всех возможностей Dart и во избежание написания множества ненужных шаблонов или копирования. Все это окажет большое влияние на читаемость, надежность и производительность кода.
Получение возможного максимума из языка может дать большую разницу между созданием неказистого, подверженного ошибкам приложения и настоящим произведением искусства. Dart обладает множеством функций, помогающих создавать интерактивный асинхронный UX, обладающий управляемым состоянием.
Ключевые понятия:
- Виджеты Flutter Container , SizedBox , и Column ;
- Использование Timer и Stopwatch в работе с интервалами времени;
- Реализация сервисов с помощью Stream / StreamController .
Все вышеперечисленное комбинируется различными способами для достижения дизайна, в котором рендеринг пользовательского интерфейса и логика управления аккуратно организованы в классы с простыми в использовании интерфейсами и свойствами.
Обзор
Архитектура и пользовательское взаимодействие бит-машины очень просты для того, чтобы имитировать драм-машины 70-х и 80-х годов, в которых были ограничены такие ресурсы, как механические переключатели, схемы и 8-битные процессоры. Также создание машин, доступных для музыкантов, требует минимизированных затрат на проектирование и сборку.
Каркас основного пользовательского интерфейса разделен на четыре виджета «передней панели», каждый из которых обеспечивает интерактивность, а машинная логика содержится в сервисе воспроизведения сэмплов и в сервисе управления звуком.
Это аналогично тому, как компоненты настоящего аппаратного инструмента передают данные через патч или MIDI-кабели (оба эти варианта до сих пор широко используются в аудиотехнике и создании музыки).
Начало приложения
Приложение инициализируется в main.dart:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_drum_machine_demo/services/sampler.dart';
import 'package:flutter_drum_machine_demo/views/display.dart';
import 'package:flutter_drum_machine_demo/views/sequencer.dart';
import 'package:flutter_drum_machine_demo/views/transport.dart';
import 'package:flutter_drum_machine_demo/views/pad-bank.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await Sampler.init();
runApp(Game());
}
class Game extends StatelessWidget {
final String _title = "Flutter Beat Machine Demo";
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: _title,
theme: ThemeData.dark(),
home: Scaffold(
appBar: AppBar(title: Center(child: Text(_title))),
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Display(),
Sequencer(),
Transport(),
PadBank()
]
),
)
);
}
}
Сначала функция main фиксирует приложение в портретной ориентации, убеждаясь в том, что все виджеты инициализированы, а затем настраивая ориентацию устройства. Строение пользовательского интерфейса остается простым благодаря столбцу, отображающему четыре основных виджета интерфейса. Рассмотрим виджеты и классы, используемые для обработки ввода данных пользователя, эффективного рендеринга и обновления пользовательского интерфейса по мере необходимости.
Основные виджеты
Четыре главных виджета в Scaffold расширяют обычный базовый класс для соединения с движком аудио. Этот класс находится в views/base-class.dart :
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
class BaseWidget extends StatefulWidget {
BaseWidget({key: Key}) : super(key: key);
@override
BaseState createState() => BaseState();
}
class BaseState extends State {
StreamSubscription _stream;
void on(Signal s) => setState(() => null);
@override
void initState() {
_stream = AudioEngine.listen(on);
super.initState();
}
@override
void dispose() {
if (_stream != null) { _stream.cancel(); }
super.dispose();
}
@override
Widget build(BuildContext context) => Container();
}
Классы BaseWidget и BaseState расширяют StatefulWidget и State соответственно и реализуют внутренний Stream, который подсоединяет слушателя к AudioEngine при инициализации и обновляет состояние при получении сигнала от сервиса. Так что каждый виджет, расширяющий BaseWidget, будет перестраиваться каждый раз, когда звуковой сервис отправляет сигнал о том, что внутри него произошло событие и пользовательский интерфейс необходимо перестроить.
Панель индикации
Самый верхний компонент в построении это views/display.dart :
import 'package:flutter/material.dart';
import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
import 'package:flutter_drum_machine_demo/views/base-widget.dart';
class BPMSelector extends StatelessWidget {
final ScrollController _controller = ScrollController();
final int itemHeight = 32;
@override
Widget build(BuildContext context) {
double listHeight = 256.toDouble() * itemHeight;
double offset = (listHeight / AudioEngine.bpm * itemHeight);
WidgetsBinding.instance.addPostFrameCallback((_) => _controller.jumpTo(offset));
TextStyle style = Theme.of(context).textTheme.headline5;
return Dialog(
backgroundColor: Colors.black38,
child: SizedBox.expand(
child: Container(
padding: EdgeInsets.all(24),
child: ListView.builder(
controller: _controller,
itemCount: 255,
itemBuilder: (context, i) => InkWell(
onTap: () {
AudioEngine.bpm = (i + 1);
Navigator.pop(context);
},
child: Container(
height: 32,
child: Center(child: Text((i+1).toString(), style: style))
)
)
)
)
)
);
}
}
class Display extends BaseWidget {
Display({Key key}) : super(key: key);
@override
_DisplayState createState() => _DisplayState();
}
class _DisplayState extends BaseState {
Color _color = Color.lerp(Colors.brown, Colors.black, 0.7);
String get _label => AudioEngine.bpm.toString() + 'bpm';
bool get _isRunning => AudioEngine.state != ControlState.READY;
int get _step => AudioEngine.step;
@override
Widget build(BuildContext context) {
double labelWidth = MediaQuery.of(context).size.width / 5;
TextStyle style = Theme.of(context).textTheme.overline;
return Container(
height: 48,
color: _color,
child: Row(
children: [
Container(
padding: EdgeInsets.all(4),
width: labelWidth,
child: Container(
decoration: BoxDecoration(
color: Colors.black26,
border: Border.all(color: Colors.pink.withOpacity(0.32)),
borderRadius: BorderRadius.circular(2),
),
child: SizedBox.expand(
child: MaterialButton(
padding: EdgeInsets.zero,
onPressed: () => showDialog(context: context, builder: (_) => BPMSelector()),
child: Text(_label, style: style)
)
)
)
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(8, (i) =>
Expanded(
child: Container(
margin: EdgeInsets.all(4),
decoration: BoxDecoration(
color: (_step == i && _isRunning) ? Colors.grey.withOpacity(0.2) : Colors.black26,
border: Border.all(color: Colors.yellow.withOpacity(0.12)),
borderRadius: BorderRadius.circular(2)
)
)
)
)
)
)
]
)
);
}
}
DisplayPanel отображает BPM и положение шага в верхней части экрана, а также автоматически обновляется при получении основным классом сигнала. При нажатии на индикатор BPM открывается диалоговое окно BPMSelector со списком вариантов от 1 до 256. Выбор одного из них настроит BPM на аудио-сервис.
Индикаторы шагов генерируются, и каждый из них «загорается» при работающем сервисе и при соответствии текущего шага индексу в каждом цикле рендеринга.
Паттерн sequencer (секвенсор)
Виджет редактора секвенсора располагается в views/sequencer.dart :
import 'package:flutter/material.dart';
import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
import 'package:flutter_drum_machine_demo/services/sampler.dart';
import 'package:flutter_drum_machine_demo/views/track.dart';
class Sequencer extends StatelessWidget {
Sequencer({Key key}) : super(key: key);
final BorderSide _border = BorderSide(color: Colors.amber.withOpacity(0.4));
@override
Widget build(BuildContext context) {
double labelWidth = MediaQuery.of(context).size.width / 5;
return Expanded(
child: Container(
decoration: BoxDecoration(
border: Border(top: _border),
color: Colors.black45,
),
child: Column(
children: List.generate(Sampler.samples.length, (i) =>
Expanded(
child: Container(
decoration: BoxDecoration(border: Border(bottom: _border)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
enableFeedback: false,
onTap: () => AudioEngine.on(PadEvent(DRUM_SAMPLE.values[i])),
child: Container(
width: labelWidth,
color: Sampler.colors[i].withOpacity(0.2),
child: Center(child: Text(Sampler.samples[DRUM_SAMPLE.values[i]]))
),
),
Track(sample: DRUM_SAMPLE.values[i])
]
)
)
)
),
)
)
);
}
}
Секвенсор — это StatelessWidget, поскольку сам он не предоставляет никакой интерактивности, но зато рендерит виджеты Track, которые дают UX каждому треку.
Для каждого сэмпла создается расширенная строка с меткой слева и дорожкой, которая автоматически расширяется, чтобы соответствовать оставшемуся пространству в строке.
Sequencer Track
Описание редактора Sequencer Track находится в views/track.dart :
import 'package:flutter/material.dart';
import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
import 'package:flutter_drum_machine_demo/services/sampler.dart';
import 'package:flutter_drum_machine_demo/views/base-widget.dart';
class Track extends BaseWidget {
Track({Key key, @required this.sample}) : super(key: key);
final DRUM_SAMPLE sample;
@override
_TrackState createState() => _TrackState();
}
class _TrackState extends BaseState {
List _data = List.generate(8, (i) => false);
bool get isRunning => AudioEngine.state != ControlState.READY;
Color get color => Sampler.colors[widget.sample.index];
Color getItemColor(int i) => (_data[i] == true)
? (i == AudioEngine.step && isRunning) ? color.withOpacity(0.6) : color.withOpacity(0.4)
: (i % 2 == 0) ? Colors.black38 : Colors.transparent;
@override
void on(Signal signal) => setState(() => _data = AudioEngine.trackdata[widget.sample]);
@override
Widget build(BuildContext context) {
return Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(8, (i) =>
Expanded(
child: SizedBox.expand(
child: InkWell(
enableFeedback: false,
onTap: () => AudioEngine.on(EditEvent(widget.sample, i)),
child: Container(
margin: EdgeInsets.symmetric(horizontal: 1),
color: getItemColor(i)
)
)
)
)
)
)
);
}
}
Виджет Track расширяет BaseWidget, и теперь он автоматически перестраивается при получении сигнала из аудио-сервиса. Каждая дорожка включает в себя сгенерированный список из восьми индикаторов нот, которые при нажатии передают событие в звуковой движок, который затем переключает состояние включения / выключения ноты внутри и сигнализирует об обновлении. Цвет каждого индикатора блока нот определяется тем, существует ли нота в текущей позиции и воспроизводится ли она в данный момент. Когда ноты нет, цвет меняется в каждом столбце для видимости и взаимодействия.
Transport Control
Виджет контроля перемещения находится в views/transport.dart :
import 'package:flutter/material.dart';
import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
import 'package:flutter_drum_machine_demo/views/base-widget.dart';
class Transport extends BaseWidget {
Transport({Key key}) : super(key: key);
@override
_TransportState createState() => _TransportState();
}
class _TransportState extends BaseState {
ControlState get state => AudioEngine.state;
Map get _icons => {
ControlState.READY: Icon(Icons.stop, color: (state == ControlState.READY) ? Colors.blue : Colors.white),
ControlState.PLAY: Icon(Icons.play_arrow, color: (state == ControlState.PLAY) ? Colors.green : Colors.white),
ControlState.RECORD: Icon(Icons.fiber_manual_record, color: (state == ControlState.RECORD) ? Colors.red : Colors.white)
};
final BoxDecoration _decoration = BoxDecoration(
color: Colors.black54,
border: Border(bottom: BorderSide(color: Colors.blueGrey.withOpacity(0.6)))
);
void onTap(ControlState state) => AudioEngine.on(ControlEvent(state));
@override
Widget build(BuildContext context) {
return Container(
decoration: _decoration,
height: 64,
padding: EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: List.generate(ControlState.values.length, (i) =>
Expanded(
child: SizedBox.expand(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 4),
child: MaterialButton(
disabledColor: Colors.black54,
onPressed: (state == ControlState.values[i]) ? null : () => onTap(ControlState.values[i]),
child: _icons[ControlState.values[i]]
)
)
)
)
)
)
);
}
}
Класс Transport создает ряд кнопок контроля перемещения, каждая из которых вызывает onTap при нажатии, запуская событие изменения состояния для сервиса, который, в свою очередь, сигнализирует об обновлении виджета через его базовый класс. Когда кнопка соответствует текущему состоянию механизма, она отключается путем передачи null методу onPressed для MaterialButton.
Pad Bank
Pad Bank определяется в views/pad-bank.dart :
import 'package:flutter/material.dart';
import 'package:flutter_drum_machine_demo/views/pad.dart';
class PadBank extends StatelessWidget {
@override
Widget build(BuildContext context) {
Size size = MediaQuery.of(context).size;
double padBankHeight = (size.height / 3);
double padHeight = padBankHeight / 2;
double padWidth = size.width / 3;
return Container(
height: padBankHeight,
color: Colors.black38,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(2, (i) =>
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(3, (j) =>
Pad(
height: padHeight,
width: padWidth,
value: 3 * i + j
)
)
)
)
)
);
}
}
PadBank расширяет StatelessWidget, и, так как он не обладает изменяемыми свойствами, то и не требует State. Этот виджет выводит Container в треть высоты доступного пространства на родительском элементе с двумя строками виджетов Pad, каждый с определенным размером и значением, полученным из текущего индекса List.
Drum Pad
Виджет drum pad определен в views/pad.dart :
import 'package:flutter/material.dart';
import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
import 'package:flutter_drum_machine_demo/services/sampler.dart';
class Pad extends StatelessWidget {
Pad({ this.height, this.width, this.value });
final double height;
final double width;
final int value;
DRUM_SAMPLE get _sample => DRUM_SAMPLE.values[value];
String get _name => Sampler.samples[_sample];
Color get _color => Sampler.colors[_sample.index];
@override
Widget build(BuildContext context) {
return SizedBox(
height: height * .82,
width: width * .88,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: _color),
borderRadius: BorderRadius.all(Radius.circular(4)),
color: _color.withOpacity(0.12)
),
child: SizedBox.expand(
child: InkWell(
enableFeedback: false,
onTap: () => AudioEngine.on(PadEvent(_sample)),
child: Center(child: Text(_name)),
)
)
)
);
}
}
Виджет Pad не имеет состояния и берет три final параметра за аргумент. Три геттера определяются для получения DRUM_SAMPLE вместе с соответствующими цветом и именем сэмпла. Когда пад нажимается, PadEvent передаются аудио-сервису для дальнейшей работы.
Теперь давайте взглянем на внутреннюю работу бит-машины.
Sampler
Определения сэмплов, загрузка и воспроизведение находятся в services/sampler.dart :
import 'dart:async';
import 'package:audioplayers/audio_cache.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
enum DRUM_SAMPLE { KICK, SNARE, HAT, TOM1, TOM2, CRASH }
abstract class Sampler {
static String _ext = '.wav';
static Map samples = const {
DRUM_SAMPLE.KICK: 'kick',
DRUM_SAMPLE.SNARE: 'snare',
DRUM_SAMPLE.HAT: 'hat',
DRUM_SAMPLE.TOM1: 'tom1',
DRUM_SAMPLE.TOM2: 'tom2',
DRUM_SAMPLE.CRASH: 'crash'
};
static List colors = [
Colors.red,
Colors.amber,
Colors.purple,
Colors.blue,
Colors.cyan,
Colors.pink,
];
static List _files = List.generate(samples.length, (i) => samples[DRUM_SAMPLE.values[i]] + _ext);
static AudioCache _cache = AudioCache(respectSilence: true);
static Future init() => _cache.loadAll(_files);
static void play(DRUM_SAMPLE sample) => _cache.play(samples[sample] + _ext, mode: PlayerMode.LOW_LATENCY);
}
Типы сэмплов определяются с DRUM_SAMPLE , а соответствующие имена файлов и цвета инициализируются в службе, которая загружает аудиофайлы во время инициализации приложения. Когда воспроизведение вызывается на сэмплере из звукового движка, воспроизводится соответствующий кэшированный аудиофайл.
AudioEngine (Аудио-сервис)
Код аудио сервиса находится в services/audio-service.dart :
import 'dart:async';
import 'package:flutter_drum_machine_demo/services/sampler.dart';
enum ControlState { READY, PLAY, RECORD }
class Event { const Event(); }
class TickEvent extends Event {}
class ControlEvent extends Event {
const ControlEvent(this.state);
final ControlState state;
}
class PadEvent extends Event {
const PadEvent(this.sample);
final DRUM_SAMPLE sample;
}
class EditEvent extends Event {
const EditEvent(this.sample, this.position);
final DRUM_SAMPLE sample;
final int position;
}
class Signal {}
abstract class AudioEngine {
// Each pattern has eight steps
static const int _resolution = 8;
static int step = 0;
// Engine control current state
static ControlState _state = ControlState.READY;
static get state => _state;
// Beats per minute
static int _bpm = 120;
static get bpm => _bpm;
static set bpm(int x) {
_bpm = x;
if (_state != ControlState.READY) { synchronize();}
_signal.add(Signal());
}
// Generates a new blank track data structure
static Map> get _blanktape =>
Map.fromIterable(DRUM_SAMPLE.values, key: (k) => k, value: (v) => List.generate(8, (i) => false));
// Track note on/off data
static Map> _trackdata = _blanktape;
static Map> get trackdata => _trackdata;
// Timer tick duration
static Duration get _tick => Duration(milliseconds: (60000 / bpm / 2).round());
static Stopwatch _watch = Stopwatch();
static Timer _timer;
// Outbound signal driver - allows widgets to listen for signals from audio engine
static StreamController _signal = StreamController.broadcast();
static Future close() => _signal.close(); // Not used but required by SDK
static StreamSubscription listen(Function(Signal) onData) => _signal.stream.listen(onData);
// Incoming event handler
static void on(Event event) {
switch (T) {
case PadEvent:
if (state == ControlState.RECORD) { return processInput(event); }
Sampler.play((event as PadEvent).sample);
return;
case TickEvent:
if (state == ControlState.READY) { return; }
return next();
case EditEvent:
return edit(event);
case ControlEvent:
return control(event);
}
}
// Controller state change handler
static control(ControlEvent event) {
switch (event.state) {
case ControlState.PLAY:
case ControlState.RECORD:
if (state == ControlState.READY) { start(); }
break;
case ControlState.READY:
default:
reset();
}
_state = event.state;
_signal.add(Signal());
}
// Note block edit event handler
static void edit(EditEvent event) {
trackdata[event.sample][event.position] = !trackdata[event.sample][event.position];
if (trackdata[event.sample][event.position]) { Sampler.play(event.sample); }
_signal.add(Signal());
}
// Quantize input using the stopwatch
static void processInput(PadEvent event) {
int position = (_watch.elapsedMilliseconds < 900) ? step : (step != 7) ? step + 1 : 0;
edit(EditEvent(event.sample, position));
}
// Reset the engine
static void reset() {
step = 0;
_watch.reset();
if (_timer != null) { _timer.cancel(); }
}
// Start the sequencer
static void start() {
reset();
_watch.start();
_timer = Timer.periodic(_tick, (t) => on(TickEvent()));
}
// Process the next step
static void next() {
step = (step == 7) ? 0 : step + 1;
_watch.reset();
trackdata.forEach((DRUM_SAMPLE sample, List track) {
if (track[step]) { Sampler.play(sample); }
});
_watch.start();
_signal.add(Signal());
}
static void synchronize() {
_watch.stop();
_timer.cancel();
_watch.start();
_timer = Timer.periodic(_tick, (t) => on(TickEvent()));
}
}
Сервис AudioEngine управляет состоянием управления перемещения, обрабатывает входные события, выполняет quantization ) входящих нот при записи, сохраняет данные дорожек и сигнализирует всем прослушивающим виджетам о необходимости обновления пользовательского интерфейса.
Классы событий определены для каждого типа требуемого события звукового сервиса, а класс Signal определен для использования в качестве универсального сигнала для обновления пользовательского интерфейса. В более сложных сценариях класс Signal может быть расширен для передачи различных типов сигналов пользовательскому интерфейсу.
Определяются разрешение и шаг, а также состояние управления, BPM, начальные данные трека, вычисления Timer / Watch / tick , а также StreamController со слушателем, позволяющим прослушивающим виджетам получать сигналы.
Когда виджет управления (например, drum pad) вызывает событие экземпляра Event, метод использует универсальные шаблоны для переключения типа события и выполнения правильного действия. Таким образом, все входящие сообщения маршрутизируются через одно место и обрабатываются соответствующим образом. Каждый тип события соответствует некоторому набору операций внутри сервиса. Каждый из control , edit , next , и synchronize запускает сигнал в пользовательский интерфейс после завершения всех обновлений.
Архитектура звукового сервиса позволяет производить обновления на лету без перезапуска сервиса, например, включение записи с простым изменением состояния, которое приведет к тому, что будущие входящие ноты будут передаваться в метод process , а также возможность настройки темпа в середине паттерна и синхронизации ( synchronize ) текущего запущенного таймера с новым BPM.
При получении EditEvent, данные события используются для изменения логического значения для состояния включения / выключения ноты для этой дорожки и позиции шага. Когда звуковой сервис запускается, создается периодический таймер, который продвигает секвенсор один раз на значение tick и вызывает next , который либо увеличивает, либо сбрасывает таймер, а затем проверяет примечание для каждой дорожки на текущем шаге, наконец сбрасывая quantization watch и сигнализируя пользовательскому интерфейсу.
Заключение
Этот проект демонстрирует возможности Flutter SDK для быстрого проектирования и разработки приложений.
Flutter — это отличный выбор для создания интерактивных кроссплатформенных приложений с высокими производительностью и надежностью. Благодаря поддержке мобильных устройств, ПК и веб-приложений разработчики могут создавать высококачественные приложения, которые везде отлично работают и просты в обслуживании, с общим синтаксисом и почти универсальной поддержкой библиотек и пакетов.
Все это значительно упрощает задачу по поддержанию актуальности большой мультиплатформенной платформы с постоянными функциями, безупречной надежностью и сверхбыстрой производительностью.
Желаю успехов в ваших будущих проектах Flutter!