ساخت اپلیکیشن موسیقی در فلاتر | به زبان ساده
«فلاتر» (Flutter) یک SDK برای ساخت اپلیکیشنهای چندپلتفرمی است که از سوی گوگل در سال 2018 معرفی شده است. فلاتر از زمان معرفی خود محبوبیت زیادی کسب کرده است. شرکتها امروزه در تلاش برای کاهش هزینهها هستند و از این رو به دنبال روشهای بهتر و کارآمدتر برای ساخت اپلیکیشنهای موبایل و به طور کلی نرمافزارهای چندپلتفرمی هستند. فلاتر از همه پلتفرمهای عمده پشتیبانی میکند. همچنین پشتیبانی از وب و همه سیستمهای عامل دسکتاپ اصلی نیز در حال توسعه است. در این مقاله یک اپلیکیشن موسیقی در فلاتر توسعه میدهیم و در این مسیر با الگوهای طراحی اصلی توسعه اپلیکیشنهای فلاتر آشنا خواهیم شد.
برای آشنایی با روش نصب فلاتر به این صفحه مستندات (+) مراجعه کنید. همچنین سورس کد کامل این پروژه در این ریپازیتوری (+) ارائه شده است.
مفاهیم ابتدایی
چند مفهوم کلیدی در فلاتر وجود دارد که به طور گستردهای در طراحی این اپلیکیشن دمو مورد استفاده قرار گرفته است. به این ترتیب از قابلیتهای زبان Dart بهره گرفتهایم و دیگر نیازی به استفاده از حجم بالایی از کد تکراری وجود ندارد. این موضوع تأثیر عمیقی بر بهبود قابلیت خوانایی، پایداری و عملکرد کد میگذارد.
بهرهگیری بهینه از قابلیتهای یک زبان، تفاوتی به اندازه تبدیل یک اپلیکیشن شلوغ و مستعد باگ به یک شاهکار هنری ایجاد میکند. زبان دارت قابلیتهای زیادی دارد که به ساخت UX ناهمگام و با تعاملپذیری بالا به همراه «مدیریت حالت» کمک میکند.
مفاهیم کلیدی مورد اشاره به شرح زیر هستند:
- ویجتهای فلاتر از قبیل Container ،SizedBox و Column
- استفاده از Timer و Stopwatch برای کار با بازههای زمانی
- پیادهسازی سرویسها با Stream / StreamController
این مفاهیم به روشهای مختلفی در سراسر این اپلیکیشن دمو ترکیب شدهاند تا به یک طراحی دست یابیم که رندرینگ UI و منطق کنترلی به طور تمیزی در کلاسهایی با اینترفیسها و مشخصههای با کاربرد آسان ترکیب شوند.
شرح اجمالی پروژه
معماری و UX اپلیکیشن ایجاد بیت موسیقی (Beat) تا حد امکان ساده حفظ شده است تا شبیه دستگاههای بیتساز دهه 190 و 1980 میلادی به نظر برسد. در این دستگاهها از منابعی مانند سوئیچهای مکانیکی، اجزای مسی و CPU-های جذاب و جدید 80 بیتی محدود بودند. در آن دوره ساخت دستگاههایی که نوازندگان توانایی خرید آن را داشته باشند، نیازمند این بود که هزینههای طراحی و ساخت در کمترین حد ممکن باقی بمانند.
چارچوب اصلی UI به چهار ویجت «پنل جلویی» تقسیم شده است که هر یک بخشی از تعاملپذیری را عرضه میکنند و منطق دستگاه درون یک سرویس playback ساده و سرویس موتور صوتی قرار گرفته است. این معماری شبیه اجزای یک آلت موسیقی سختافزاری واقعی است که دادهها را از طریق کابلهای MIDI ارسال میکند.
نقطه ورودی اپلیکیشن
اپلیکیشن ما درون فایل main.dart مقداردهی میشود:
1import 'package:flutter/material.dart';
2import 'package:flutter/services.dart';
3import 'package:flutter_drum_machine_demo/services/sampler.dart';
4import 'package:flutter_drum_machine_demo/views/display.dart';
5import 'package:flutter_drum_machine_demo/views/sequencer.dart';
6import 'package:flutter_drum_machine_demo/views/transport.dart';
7import 'package:flutter_drum_machine_demo/views/pad-bank.dart';
8
9void main() async {
10
11 WidgetsFlutterBinding.ensureInitialized();
12 SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
13
14 await Sampler.init();
15 runApp(Game());
16}
17
18class Game extends StatelessWidget {
19
20 final String _title = "Flutter Beat Machine Demo";
21
22 @override
23 Widget build(BuildContext context) {
24
25 return MaterialApp(
26 debugShowCheckedModeBanner: false,
27 title: _title,
28 theme: ThemeData.dark(),
29 home: Scaffold(
30 appBar: AppBar(title: Center(child: Text(_title))),
31 body: Column(
32 mainAxisAlignment: MainAxisAlignment.spaceBetween,
33 children: [
34 Display(),
35 Sequencer(),
36 Transport(),
37 PadBank()
38 ]
39 ),
40 )
41 );
42 }
43}
تابع main موجب میشود که اپلیکیشن در همان ابتدا در حالت افقی قفل شود و تنها پس از اطمینان یافتن از این که ویجتها مقداردهی شدند، جهتگیری دستگاه مورد استفاده قرار میگیرد. چارچوب UI ساده است و یک ستون برای نمایش چهار ویجت اینترفیس وجود دارد. در ادامه این ویجتها و کلاسهای مورد استفاده برای مدیریت ورودی کاربر و رندر کارآمد و رفرش UI در موارد نیاز را مورد بررسی قرار میدهیم.
ویجت پایه
چهار ویجت اصلی در چارچوب (main) یک کلاس مشترک مبنا را بسط میدهند تا به موتور صوتی وصل شوند.
این کلاس در مسیر views/base-class.dart تعریف شده است:
1import 'dart:async';
2import 'package:flutter/material.dart';
3import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
4
5class BaseWidget extends StatefulWidget {
6
7 BaseWidget({ key: Key }) : super(key: key);
8
9 @override
10 BaseState createState() => BaseState();
11}
12
13class BaseState<T extends BaseWidget> extends State<T> {
14
15 StreamSubscription<Signal> _stream;
16
17 void on<Signal>(Signal s) => setState(() => null);
18
19 @override
20 void initState() {
21
22 _stream = AudioEngine.listen(on);
23 super.initState();
24 }
25
26 @override
27 void dispose() {
28
29 if (_stream != null) { _stream.cancel(); }
30 super.dispose();
31 }
32
33 @override
34 Widget build(BuildContext context) => Container();
35}
کلاسهای BaseWidget و BaseeState به ترتیب اقدام به بسط StatefulWidget و State میکنند و یک استریم داخلی را پیادهسازی میکنند که در زمان مقداردهی اولیه، یک شنونده را به AudioEngine متصل میسازد و هنگامی که سیگنالی از موتور صوتی دریافت شود، حالت را رفرش میکند. از این رو هر ویجت که AudioEngine را بسط دهد، در مواردی که موتور صوتی سیگنالی ارسال کند که رویدادی درون موتور رخ داده است، بازسازی میشود و از این رو UI باید از نو ساخته شود.
پنل نمایش
بالاترین کامپوننت در ستون scaffold به نام پنل نمایش در مسیر views/display.dart قرار دارد:
1import 'package:flutter/material.dart';
2import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
3import 'package:flutter_drum_machine_demo/views/base-widget.dart';
4
5class BPMSelector extends StatelessWidget {
6
7 final ScrollController _controller = ScrollController();
8
9 final int itemHeight = 32;
10
11 @override
12 Widget build(BuildContext context) {
13
14 double listHeight = 256.toDouble() * itemHeight;
15 double offset = (listHeight / AudioEngine.bpm * itemHeight);
16
17 WidgetsBinding.instance.addPostFrameCallback((_) => _controller.jumpTo(offset));
18
19 TextStyle style = Theme.of(context).textTheme.headline5;
20
21 return Dialog(
22 backgroundColor: Colors.black38,
23 child: SizedBox.expand(
24 child: Container(
25 padding: EdgeInsets.all(24),
26 child: ListView.builder(
27 controller: _controller,
28 itemCount: 255,
29 itemBuilder: (context, i) => InkWell(
30 onTap: () {
31 AudioEngine.bpm = (i + 1);
32 Navigator.pop(context);
33 },
34 child: Container(
35 height: 32,
36 child: Center(child: Text((i+1).toString(), style: style))
37 )
38 )
39 )
40 )
41 )
42 );
43 }
44}
45
46class Display extends BaseWidget {
47
48 Display({Key key}) : super(key: key);
49
50 @override
51 _DisplayState createState() => _DisplayState();
52}
53
54class _DisplayState extends BaseState<Display> {
55
56 Color _color = Color.lerp(Colors.brown, Colors.black, 0.7);
57
58 String get _label => AudioEngine.bpm.toString() + 'bpm';
59 bool get _isRunning => AudioEngine.state != ControlState.READY;
60 int get _step => AudioEngine.step;
61
62 @override
63 Widget build(BuildContext context) {
64
65 double labelWidth = MediaQuery.of(context).size.width / 5;
66 TextStyle style = Theme.of(context).textTheme.overline;
67
68 return Container(
69 height: 48,
70 color: _color,
71 child: Row(
72 children: <Widget>[
73 Container(
74 padding: EdgeInsets.all(4),
75 width: labelWidth,
76 child: Container(
77 decoration: BoxDecoration(
78 color: Colors.black26,
79 border: Border.all(color: Colors.pink.withOpacity(0.32)),
80 borderRadius: BorderRadius.circular(2),
81 ),
82 child: SizedBox.expand(
83 child: MaterialButton(
84 padding: EdgeInsets.zero,
85 onPressed: () => showDialog(context: context, builder: (_) => BPMSelector()),
86 child: Text(_label, style: style)
87 )
88 )
89 )
90 ),
91 Expanded(
92 child: Row(
93 crossAxisAlignment: CrossAxisAlignment.stretch,
94 children: List<Widget>.generate(8, (i) =>
95 Expanded(
96 child: Container(
97 margin: EdgeInsets.all(4),
98 decoration: BoxDecoration(
99 color: (_step == i && _isRunning) ? Colors.grey.withOpacity(0.2) : Colors.black26,
100 border: Border.all(color: Colors.yellow.withOpacity(0.12)),
101 borderRadius: BorderRadius.circular(2)
102 )
103 )
104 )
105 )
106 )
107 )
108 ]
109 )
110 );
111 }
112}
کلاس DisplayPanel نشانگرهای موقعیت BPM و Step را در ابتدای صفحه رندر میکند و زمانی که کلاس مبنا سیگنالی دریافت کند، به صورت خودکار رفرش میشود. با کلیک کردن روی نشانگر BPM یک دیالوگ BPMSelector به همراه فهرستی از گزینههای عددی از 1 تا 256 باز میشود. با انتخاب یکی از این گزینهها میزان BPM روی موتور صوتی تنظیم میشود.
نشانگرهای Step نیز تولید میشوند که هر یک از آنها زمانی که موتور اجرا شود و گام کنونی با شاخص روی چرخه رندر تطبیق پیدا کند، روشن میشوند.
تقطیعکننده الگو
ویجت ادیتور «تقطیعکننده الگو» (Pattern Sequencer) در مسیر views/sequencer.dart تعریف میشود:
1import 'package:flutter/material.dart';
2import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
3import 'package:flutter_drum_machine_demo/services/sampler.dart';
4import 'package:flutter_drum_machine_demo/views/track.dart';
5
6class Sequencer extends StatelessWidget {
7
8 Sequencer({Key key}) : super(key: key);
9
10 final BorderSide _border = BorderSide(color: Colors.amber.withOpacity(0.4));
11
12 @override
13 Widget build(BuildContext context) {
14
15 double labelWidth = MediaQuery.of(context).size.width / 5;
16
17 return Expanded(
18 child: Container(
19 decoration: BoxDecoration(
20 border: Border(top: _border),
21 color: Colors.black45,
22 ),
23 child: Column(
24 children: List<Widget>.generate(Sampler.samples.length, (i) =>
25 Expanded(
26 child: Container(
27 decoration: BoxDecoration(border: Border(bottom: _border)),
28 child: Row(
29 crossAxisAlignment: CrossAxisAlignment.stretch,
30 children: <Widget>[
31 InkWell(
32 enableFeedback: false,
33 onTap: () => AudioEngine.on<PadEvent>(PadEvent(DRUM_SAMPLE.values[i])),
34 child: Container(
35 width: labelWidth,
36 color: Sampler.colors[i].withOpacity(0.2),
37 child: Center(child: Text(Sampler.samples[DRUM_SAMPLE.values[i]]))
38 ),
39 ),
40 Track(sample: DRUM_SAMPLE.values[i])
41 ]
42 )
43 )
44 )
45 ),
46 )
47 )
48 );
49 }
50}
این سکوئنسر یک «ویجت بیحالت» (StatelessWidget) است، چون هیچ نوع تعاملپذیری از خود ندارد، اما به جای آن ویجتهای Track را که UX هر ترک را ارائه میکنند رندر میکند.
یک ردیف بسطیافته نیز برای هر سمپل (نمونه) تولید میشود که دارای یک برچسب در سمت چپ است و یک Track دارد که به صورت خودکار باز شده و باقی فضای ردیف را پر میکند.
ترک سکوئنسر
ادیتور تِرَک سکانس در مسیر views/track.dart تعریف شده است:
1import 'package:flutter/material.dart';
2import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
3import 'package:flutter_drum_machine_demo/services/sampler.dart';
4import 'package:flutter_drum_machine_demo/views/base-widget.dart';
5
6class Track extends BaseWidget {
7
8 Track({ Key key, @required this.sample }) : super(key: key);
9
10 final DRUM_SAMPLE sample;
11
12 @override
13 _TrackState createState() => _TrackState();
14}
15
16class _TrackState extends BaseState<Track> {
17
18 List<bool> _data = List.generate(8, (i) => false);
19
20 bool get isRunning => AudioEngine.state != ControlState.READY;
21
22 Color get color => Sampler.colors[widget.sample.index];
23
24 Color getItemColor(int i) => (_data[i] == true)
25 ? (i == AudioEngine.step && isRunning) ? color.withOpacity(0.6) : color.withOpacity(0.4)
26 : (i % 2 == 0) ? Colors.black38 : Colors.transparent;
27
28 @override
29 void on<Signal>(Signal signal) => setState(() => _data = AudioEngine.trackdata[widget.sample]);
30
31 @override
32 Widget build(BuildContext context) {
33
34 return Expanded(
35 child: Row(
36 crossAxisAlignment: CrossAxisAlignment.stretch,
37 children: List<Widget>.generate(8, (i) =>
38 Expanded(
39 child: SizedBox.expand(
40 child: InkWell(
41 enableFeedback: false,
42 onTap: () => AudioEngine.on<EditEvent>(EditEvent(widget.sample, i)),
43 child: Container(
44 margin: EdgeInsets.symmetric(horizontal: 1),
45 color: getItemColor(i)
46 )
47 )
48 )
49 )
50 )
51 )
52 );
53 }
54}
ویجت Track در واقع «ویجت پایه» (BaseWidget) را بسط داده است و از این رو هر زمان که یک سیگنال از موتور صوتی دریافت کند، بازسازی میشود. هر ترک درون خود یک فهرست از هشت نشانگر نُت دارد که وقتی کلیک شود، یک رویداد به موتور صوتی ارسال میکند. به این ترتیب حالت نت به صورت داخلی خاموش/روشن میشود و علامتی برای یک رفرش فرستاده میشود. رنگ هر نشانگر بلوک نت از روی این مسئله که نت در موقعیت کنونی موجود باشد و این که نت در حال پخش باشد یا نه، تعیین میشود. زمانی که یک نت موجود نباشد، رنگ ستونهای مجاور به منظور خوانایی و UX تغییر مییابد.
کنترل انتقال
در این بخش ویجت «کنترل انتقال» (Transport Control) را در مسیر views/transport.dart تعریف میکنیم:
1import 'package:flutter/material.dart';
2import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
3import 'package:flutter_drum_machine_demo/views/base-widget.dart';
4
5class Transport extends BaseWidget {
6
7 Transport({Key key}) : super(key: key);
8
9 @override
10 _TransportState createState() => _TransportState();
11}
12
13class _TransportState extends BaseState<Transport> {
14
15 ControlState get state => AudioEngine.state;
16
17 Map<ControlState, Icon> get _icons => {
18 ControlState.READY: Icon(Icons.stop, color: (state == ControlState.READY) ? Colors.blue : Colors.white),
19 ControlState.PLAY: Icon(Icons.play_arrow, color: (state == ControlState.PLAY) ? Colors.green : Colors.white),
20 ControlState.RECORD: Icon(Icons.fiber_manual_record, color: (state == ControlState.RECORD) ? Colors.red : Colors.white)
21 };
22
23 final BoxDecoration _decoration = BoxDecoration(
24 color: Colors.black54,
25 border: Border(bottom: BorderSide(color: Colors.blueGrey.withOpacity(0.6)))
26 );
27
28 void onTap(ControlState state) => AudioEngine.on<ControlEvent>(ControlEvent(state));
29
30 @override
31 Widget build(BuildContext context) {
32
33 return Container(
34 decoration: _decoration,
35 height: 64,
36 padding: EdgeInsets.symmetric(horizontal: 8),
37 child: Row(
38 children: List<Widget>.generate(ControlState.values.length, (i) =>
39 Expanded(
40 child: SizedBox.expand(
41 child: Container(
42 padding: EdgeInsets.symmetric(horizontal: 4),
43 child: MaterialButton(
44 disabledColor: Colors.black54,
45 onPressed: (state == ControlState.values[i]) ? null : () => onTap(ControlState.values[i]),
46 child: _icons[ControlState.values[i]]
47 )
48 )
49 )
50 )
51 )
52 )
53 );
54 }
55}
کلاس Transport یک ردیف از دکمههای کنترل انتقال ایجاد میکند که هر کدام از آنها را وقتی بزنیم یک فراخوانی onTap ایجاد میکند و رویداد تغییر حالت به موتور ارسال میشود که به نوبه خود از طریق ویجت پایه یک رفرش را روی این ویجت علامتدهی میکند. زمانی که یک دکمه با حالت کنونی موتور تطبیق پیدا کند، با ارسال مقدار null به متد onPressed مربوط به MaterialButton غیر فعال میشود.
پد بانک
پد بانک درام در مسیر views/pad-bank.dart تعریف میشود:
1import 'package:flutter/material.dart';
2import 'package:flutter_drum_machine_demo/views/pad.dart';
3
4class PadBank extends StatelessWidget {
5
6 @override
7 Widget build(BuildContext context) {
8
9 Size size = MediaQuery.of(context).size;
10 double padBankHeight = (size.height / 3);
11 double padHeight = padBankHeight / 2;
12 double padWidth = size.width / 3;
13
14 return Container(
15 height: padBankHeight,
16 color: Colors.black38,
17 child: Column(
18 mainAxisAlignment: MainAxisAlignment.spaceEvenly,
19 children: List<Widget>.generate(2, (i) =>
20 Row(
21 mainAxisAlignment: MainAxisAlignment.spaceEvenly,
22 children: List<Widget>.generate(3, (j) =>
23 Pad(
24 height: padHeight,
25 width: padWidth,
26 value: 3 * i + j
27 )
28 )
29 )
30 )
31 )
32 );
33 }
34}
ویجت PadBank نیز ویجت StatelessWidget را بسط میدهد و از این رو هیچ مشخصه «تغییرپذیر» (mutable) ندارد. به همین جهت این ویجت نیازی به حالت ندارد. این ویجت یک کانتینر با 3/1 ارتفاع فضای موجود روی والا خود با دو ردیف ویجتهای Pad رندر میکند که هر یک اندازه تعریفشدهای دارند و مقدار آن نیز از اندیس لیست جاری گرفته میشود.
پد درام
ویجت پد درام در مسیر views/pad.dart تعریف میشود:
1import 'package:flutter/material.dart';
2import 'package:flutter_drum_machine_demo/services/audio-engine.dart';
3import 'package:flutter_drum_machine_demo/services/sampler.dart';
4
5class Pad extends StatelessWidget {
6
7 Pad({ this.height, this.width, this.value });
8
9 final double height;
10 final double width;
11 final int value;
12
13 DRUM_SAMPLE get _sample => DRUM_SAMPLE.values[value];
14 String get _name => Sampler.samples[_sample];
15 Color get _color => Sampler.colors[_sample.index];
16
17 @override
18 Widget build(BuildContext context) {
19
20 return SizedBox(
21 height: height * .82,
22 width: width * .88,
23 child: Container(
24 alignment: Alignment.center,
25 decoration: BoxDecoration(
26 border: Border.all(color: _color),
27 borderRadius: BorderRadius.all(Radius.circular(4)),
28 color: _color.withOpacity(0.12)
29 ),
30 child: SizedBox.expand(
31 child: InkWell(
32 enableFeedback: false,
33 onTap: () => AudioEngine.on<PadEvent>(PadEvent(_sample)),
34 child: Center(child: Text(_name)),
35 )
36 )
37 )
38 );
39 }
40}
ویجت پد یک ویجت بیحالت است که سه پارامتر تغییرناپذیر نهایی (final) به عنوان آرگومان میگیرد. سه مشخصه get تعریف شدهاند تا DRUM_SAMPLE را همراه با نام و رنگ سمپل مربوطه دریافت کنند. زمانی که روی یک پد بزنید، یک PadEvent به موتور صوتی ارسال میشود تا در آنجا پردازش شود. در ادامه به بررسی طرز کار داخلی این اپلیکیشن ایجاد بیت میپردازیم.
سمپلر
تعاریف سمپل و بارگذاری/بازپخش در مسیر services/sampler.dart تعریف شدهاند:
1import 'dart:async';
2import 'package:audioplayers/audio_cache.dart';
3import 'package:audioplayers/audioplayers.dart';
4import 'package:flutter/material.dart';
5
6enum DRUM_SAMPLE { KICK, SNARE, HAT, TOM1, TOM2, CRASH }
7
8abstract class Sampler {
9
10 static String _ext = '.wav';
11
12 static Map<DRUM_SAMPLE, String> samples = const {
13 DRUM_SAMPLE.KICK: 'kick',
14 DRUM_SAMPLE.SNARE: 'snare',
15 DRUM_SAMPLE.HAT: 'hat',
16 DRUM_SAMPLE.TOM1: 'tom1',
17 DRUM_SAMPLE.TOM2: 'tom2',
18 DRUM_SAMPLE.CRASH: 'crash'
19 };
20
21 static List<Color> colors = [
22 Colors.red,
23 Colors.amber,
24 Colors.purple,
25 Colors.blue,
26 Colors.cyan,
27 Colors.pink,
28 ];
29
30 static List<String> _files = List.generate(samples.length, (i) => samples[DRUM_SAMPLE.values[i]] + _ext);
31
32 static AudioCache _cache = AudioCache(respectSilence: true);
33
34 static Future<void> init() => _cache.loadAll(_files);
35
36 static void play(DRUM_SAMPLE sample) => _cache.play(samples[sample] + _ext, mode: PlayerMode.LOW_LATENCY);
37}
انواع سمپل با استفاده از DRUM_SAMPLE تعریف شدهاند و نامهای فایل و رنگهای متناظر روی سرویس مقداردهی میشوند که فایلهای صوتی را در طی فاز مقداردهی اولیه اپلیکیشن بارگذاری میکند. زمانی که متد play روی سمپلر از سوی موتور صوتی فراخوانی شود، فایل صوتی کششده متناظر نواخته میشود.
موتور صوتی
در این بخش به بررسی سرویس صوتی میپردازیم که در مسیر services/audio-service.dart تعریف شده است:
1import 'dart:async';
2import 'package:flutter_drum_machine_demo/services/sampler.dart';
3
4enum ControlState { READY, PLAY, RECORD }
5
6class Event { const Event(); }
7class TickEvent extends Event {}
8
9class ControlEvent extends Event {
10 const ControlEvent(this.state);
11 final ControlState state;
12}
13
14class PadEvent extends Event {
15 const PadEvent(this.sample);
16 final DRUM_SAMPLE sample;
17}
18
19class EditEvent extends Event {
20 const EditEvent(this.sample, this.position);
21 final DRUM_SAMPLE sample;
22 final int position;
23}
24
25class Signal {}
26
27abstract class AudioEngine {
28
29 // Each pattern has eight steps
30 static const int _resolution = 8;
31 static int step = 0;
32
33 // Engine control current state
34 static ControlState _state = ControlState.READY;
35 static get state => _state;
36
37 // Beats per minute
38 static int _bpm = 120;
39 static get bpm => _bpm;
40 static set bpm(int x) {
41
42 _bpm = x;
43 if (_state != ControlState.READY) { synchronize();}
44 _signal.add(Signal());
45 }
46
47 // Generates a new blank track data structure
48 static Map<DRUM_SAMPLE, List<bool>> get _blanktape =>
49 Map.fromIterable(DRUM_SAMPLE.values, key: (k) => k, value: (v) => List.generate(8, (i) => false));
50
51 // Track note on/off data
52 static Map<DRUM_SAMPLE, List<bool>> _trackdata = _blanktape;
53 static Map<DRUM_SAMPLE, List<bool>> get trackdata => _trackdata;
54
55 // Timer tick duration
56 static Duration get _tick => Duration(milliseconds: (60000 / bpm / 2).round());
57 static Stopwatch _watch = Stopwatch();
58 static Timer _timer;
59
60 // Outbound signal driver - allows widgets to listen for signals from audio engine
61 static StreamController<Signal> _signal = StreamController<Signal>.broadcast();
62 static Future<void> close() => _signal.close(); // Not used but required by SDK
63 static StreamSubscription<Signal> listen(Function(Signal) onData) => _signal.stream.listen(onData);
64
65 // Incoming event handler
66 static void on<T extends Event>(Event event) {
67
68 switch (T) {
69
70 case PadEvent:
71 if (state == ControlState.RECORD) { return processInput(event); }
72 Sampler.play((event as PadEvent).sample);
73 return;
74
75 case TickEvent:
76 if (state == ControlState.READY) { return; }
77 return next();
78
79 case EditEvent:
80 return edit(event);
81
82 case ControlEvent:
83 return control(event);
84 }
85 }
86
87 // Controller state change handler
88 static control(ControlEvent event) {
89
90 switch (event.state) {
91
92 case ControlState.PLAY:
93 case ControlState.RECORD:
94 if (state == ControlState.READY) { start(); }
95 break;
96
97 case ControlState.READY:
98 default:
99 reset();
100 }
101
102 _state = event.state;
103 _signal.add(Signal());
104 }
105
106 // Note block edit event handler
107 static void edit(EditEvent event) {
108
109 trackdata[event.sample][event.position] = !trackdata[event.sample][event.position];
110 if (trackdata[event.sample][event.position]) { Sampler.play(event.sample); }
111 _signal.add(Signal());
112 }
113
114 // Quantize input using the stopwatch
115 static void processInput(PadEvent event) {
116
117 int position = (_watch.elapsedMilliseconds < 900) ? step : (step != 7) ? step + 1 : 0;
118 edit(EditEvent(event.sample, position));
119 }
120
121 // Reset the engine
122 static void reset() {
123
124 step = 0;
125 _watch.reset();
126 if (_timer != null) { _timer.cancel(); }
127 }
128
129 // Start the sequencer
130 static void start() {
131
132 reset();
133 _watch.start();
134 _timer = Timer.periodic(_tick, (t) => on<TickEvent>(TickEvent()));
135 }
136
137 // Process the next step
138 static void next() {
139
140 step = (step == 7) ? 0 : step + 1;
141 _watch.reset();
142
143 trackdata.forEach((DRUM_SAMPLE sample, List<bool> track) {
144 if (track[step]) { Sampler.play(sample); }
145 });
146
147 _watch.start();
148 _signal.add(Signal());
149 }
150
151 static void synchronize() {
152
153 _watch.stop();
154 _timer.cancel();
155
156 _watch.start();
157 _timer = Timer.periodic(_tick, (t) => on<TickEvent>(TickEvent()));
158 }
159}
سرویس «موتور صوتی» (AudioEngine) حالت «کنترل انتقال» را مدیریت میکند، رویدادهای ورودی را اداره کرده و فرایند «کمّیسازی» (quantization) را در زمان ضبط کردن، روی نتهای ورودی اجرا میکند. همچنین موتور صوتی دادههای ترک را ذخیره کرده و به همه ویجتهایی که گوش میدهند بسته به نیاز علامت میدهد که UI را رفرش کنند.
کلاسهای Event برای هر نوع از رویدادی که موتور صوتی نیاز دارد، تعریف شدهاند و یک کلاس placeholder Signal نیز تعریف شده است تا به عنوان یک سیگنال با کاربرد عمومی برای رفرش کردن UI مورد استفاده قرار گیرد. در سناریوهای پیچیدهتر، میتوان کلاس Signal را برای ارسال انواع مختلفی از سیگنالها به UI بسط داد.
«وضوح الگو» (Pattern Resolution) و Step همراه با حالت کنترل، BPM، دادههای ترک اولیه، محاسبات Timer / Watch / _tick تعریف شدهاند. همچنین یک StreamController به همراه listener تعریف شدهاند تا ویجتها بتوانند به سیگنالهای دریافتی گوش دهند.
زمانی که سطح کنترل (مانند پد درام) on را با یک وهله از Event فراخوانی کند، متد از ژنریکها برای سوئیچ کردن به نوع رویداد و اجرای عمل صحیح استفاده میکند. به این ترتیب همه پیامهای ورودی از طریق یک مکان مسیریابی میشوند و بر همین اساس مدیریت خواهند شد. هر نوع رویداد با یک سری از عملیات درون موتور متناظر است. متدهای control ،edit ،next و synchronize در زمانی که همه بهروزرسانیها تکمیل شدند، هر کدام یک سیگنال را به UI میفرستند.
طراحی موتور صوتی به ما امکان میدهد که UI را به صورت درجا (on-the-fly) بهروزرسانی کنیم و نیازی به راهاندازی مجدد موتور وجود ندارد. به این ترتیب امکان ضبط با یک تغییر حالت ساده میسر میشود و با این تغییر نتهای ورودی آتی باید از متد process بگذرند تا بتوانند تمپو را در میانه الگو تنظیم کنند. همچنین از یک متد synchronize برای تنظیم تایمر جاری به BPM جدید استفاده میشود.
وقتی که رویداد EditEvent دریافت شود، دادههای این رویداد برای عوض کردن آن مقدار بولی که نمایانگر روشن/خاموش بودن نت برای این ترک و موقعیت گام است مورد استفاده قرار میگیرند. زمانی که موتور صوتی آغاز شود، یک تایمر دورهای ایجاد میشود که با هر مقدار _tick سکوئنسر را به پیش میبرد و next را فرا میخواند که تایمر را افزایش داده یا ریست میکند و سپس نت را برای هر ترک روی گام جاری بررسی کرده و در نهایت کمّیسازی _watch را ریست کرده و سیگنالی به UI ارسال میکند.
سخن پایانی
در این مقاله یک پروژه را معرفی کردیم که قدرت Flutter SDK را برای طراحی و توسعه سریع اپلیکیشنها نشان میدهد. فلاتر گزینهای عالی برای ساخت اپلیکیشنهای چندپلتفرمی با تعاملپذیری و پایداری بالا است. این SDK با پشتیبانی از پلتفرمهای موبایل، دسکتاپ و وب، به توسعهدهندگان امکان میدهد که اپلیکیشنهایی با کیفیت بالا بسازند که در هر محیطی عملکردی عالی دارند و نگهداری آنها آسان است.
چنین اپلیکیشنهایی دارای ساختار مشترک بوده و یک پشتیبانی تقریباً سراسری از کتابخانهها و پکیجها ارائه میکنند. به این ترتیب وظیفه بهروز نگهداشتن یک اپلیکیشن بزرگ چندپلتفرمی با فیچرهای سازگار، پایداری کاملاً مستحکم و عملکرد کاملاً سریع به مقدار زیادی ساده میشود.