Создание бит-машины во Flutter

Егор Долгов · 05.04.2021
This article was written for an older Flutter version and is being updated. Some code may not compile on the current version.

Эта статья о том, как построить бит-машину с секвенсором в Dart/Flutter.

Автор оригинального текста Ken Reilly. Оригинальная статья тут .

Пример приложения

Вступление

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

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

Чтобы получить и запустить окружение Flutter, посетите эту страницу загрузки . А чтобы получить копию исходного коды этого проекта, зайдите на этот репозиторий .

Ключевые понятия

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

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

Ключевые понятия:

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

Обзор

Архитектура и пользовательское взаимодействие бит-машины очень просты для того, чтобы имитировать драм-машины 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!