こんにちは。白羽(shiroha_F14)です。
絶対にシリーズ化したいFlutter/Dart開発の続きです。
#注意書き
開発内容などは前回の記事をご覧ください。
#今日やったこと
環境構築を終えたらAndroid StudioでFlutterプロジェクトを作れるようになっていたので、既存の記事を探しつつプログラムを書いてみることにしました。
声をシンセ化するとしたら、まずは声を録音できないと話になりません。よって今回は声を録音します。
##参考にしたサイトなど
###注意点
Dart1?では一般的だったもののDart2ではDeprecatedな要素がいくつかありますが、チュートリアルの方のサイトではまだそれらが更新されていません。本記事ではそれらについて変更点を交えて記述していきます。
##作業内容
ひとまず、チュートリアルの流れに沿ってプログラムを書いてみました。
###得た知見
-
文法が結構Java & JavaScript
- JavaScript要素
- アロー関数的な記法が使える
- 画面上の要素はjsonみたいな形で書き並べている(どちらかというとjson)
- 多分finalとかvarで宣言すると型推論される
- Java要素
- 仮引数に型名を宣言する
-
@override
アノテーション - 型推論を使わずに 型名 名前 でも宣言できる
- Dart独自?
- 画面上の要素の最小単位はWidget
- private宣言は名前の冒頭にアンダースコア
- JavaScript要素
-
外部パッケージの依存関係記述はyaml形式。めっちゃJavaのgradleっぽい
###所感
結構知ってる言語の文法に近いので、個人的にはxmlでレイアウトを操作するJava/Kotlin開発より楽な気がしました。逆にxml操作に慣れてる方からすると難しいかも……
##録音
本題の録音を行うプログラムに入ります。
GoogleでFlutter record audioと検索するとこれがヒットします。
Readmeのタブを読んでみます。
###アプリ権限の設定
Platformsの欄にやたら変な記述が2行ありますが、これは後ほど調べたところ
(ルートフォルダ)->android->app->main->AndroidManifest.xml
に記述するアプリの権限設定であることが解りました。
よってこれを前述のファイルへと追記します。
具体的には
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
を、AndroidManifest.xml
の
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.(your_project_name)">
の直下へインデントを合わせて追記します。よって私のファイルは、
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.(your_project_name)">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
となりました。
Windows使いなのでiOSには縁がない気がしますが、知識のある方はその下にあるiOS用権限設定も追加しましょう。
これによって以降の起動中一回だけアプリに音声録音とストレージ書き込みを許可するダイアログが出現します。
###依存関係の追加
続けて、Usage欄を確認。
プログラムの冒頭にいきなりimportが来ていますが、チュートリアルでyaml記述しなかったっけ?と思ってそこら辺のタブを確認しました。画面上部にInstallingというボタンがあり、それを押すとyamlへの記述内容が明記してあったので、まず最初にこれをpubspec.yaml
へと追記しましょう。
具体的には、
(ルートフォルダ)->pubspec.yaml
の
dev_dependencies:
flutter_test:
sdk: flutter
と記述されている部分の真下(チュートリアルを通った場合はenglish_wordsがこの下にありますので、その真下)
record: ^2.1.1
を追記します。インデントをflutter_testの項目と合わせてください。結果として私の場合は、
dev_dependencies:
flutter_test:
sdk: flutter
record: ^2.1.1
という形になりました。
最後にAndroid Studioのターミナルから
flutter pub get
を実行し、依存関係ファイルをダウンロードさせます。
###プログラミング
ようやくプログラミングに入ることが出来ます。
まずはimport文を追加します。プログラム冒頭に
import 'package:record/record.dart';
を追記してください。
####外枠
#####外見
まずはボタンを押すと録音が開始され、再度ボタンを押すと録音を終了してそれを再生するだけのものを作ります。
#####機能
- ボタンを押した際、上のテキストが「録音中」に変更。同時に録音も開始。
- 録音中にボタンを再度押すと録音を停止。テキストは「録音待機」に戻り、更に録音した音声を再生する。
#####実装手順
前述のチュートリアルを通した方ならば既にウィンドウが立ち上がるようなdartプログラムファイルが存在していると思われます。
最初にエントリポイントであるvoid main()
を改鋳します。
void main() {
runApp(RecordScreen());
}
この時点ではRecordScreen()
にエラーが出ると思われます。
個人的にここを改変する理由はMyApp
というネーミングが後々めんどくさそうだったからです。気にならない方はそのままで良いかもしれません。
これを改変後、次にmain.dart
のあるlib
フォルダと同階層のtest
フォルダを除きます。すると恐らくエラーが出現しているので、そちらをMyApp()
からRecordScreen()
に変更します。
そしてmain.dart
へ戻ります。
runApp()
の実引数は任意の「Widget
をextendしたクラス」のインスタンスであることが求められます。
そして、Widget
をextendしたクラスについてですが、これは2種類存在しており、
- StatelessWidget
- 中の要素に対して「このスコープから」変更が加えられることがない。
つまり中の要素が別のWidgetでありそれがStatefulである場合は十分考えられる。- const宣言してもメソッドは呼び出せるJavaScriptのDOM操作と似たものを感じる
- タイトルバーなど書き換わることのない文字などで使用
- 中の要素に対して「このスコープから」変更が加えられることがない。
- StatefulWidget
- クラス内に作成したメソッドからsetState()というメソッドを呼び出せる。
setState()呼び出し時に引数として渡す無名関数について、その内容がそのまま実行された後、Widgetが再描画される。
- クラス内に作成したメソッドからsetState()というメソッドを呼び出せる。
従って、まずは画面の大枠を定義したく、今のところは画面中の要素が増える予定はないので、
class RecordScreen extends StatelessWidget {
// -----
}
これをvoid main()
の後に追記することとなります。
大枠の画面はタイトルバーとそれ以外の部分で構成されています。それをプログラムに起こすとこのようになります。
class RecordScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Record',
home: Scaffold(
appBar: AppBar(
title: Text('Record'),
),
body: Center(
// WindowBody()は後述
child: WindowBody(),
),
),
);
}
}
build()
メソッドの返り値が「このWidget
の構成部品」であり、呼び出されればreturn
に示されたMaterialApp(……)
が評価されて画面に描画されます。
build()
はStatelessWidget
から継承されるメソッドなので@override
アノテーションを付加しています。
Scaffold(……)
とはまさに前述の画面のような構造のレイアウト構造です。
appBar:
に指定されたAppBar(……)
が画面上部のバーで、
body:
に指定されたCenter(……)
が中央揃えになった本体部のWidgetになります。
AppBar()
のtitle
という部分にはText()
というWidgetを指定します。これは文字通り文字列のWidgetであり、その内容が丸ごとタイトルバーになります。
(この後もう一度出現するText()
はこれと比べて大きく形が異なりますが、同一なものです。)
そして、child:
に指定されたWindowBody()
が前述のStatefulWidget
であり、これから記述していく部分です。正確にはCenter()
というWidgetの子要素です。
####本体
タイトルバーより下、本題の部分に入ります。
この部分には「録音待機」と「録音中」を行ったり来たりするテキストがあるので、StatefulWidget
を用います。
#####言語仕様的な話
ここは個人的には僅かに難解だと感じたポイントなのですが、
StatefulWidget
にはbuild()
はなく、createState()
を使ってState<>
という型のオブジェクトを返すような構造になっています。
そしてそのState<>
型オブジェクトがStatefulWidget
の機能的な部分を実装する部分になっています。
従って、WindowBody()
という宣言で呼び出されるclass WIndowBody
自体は
class WindowBody extends StatefulWidget {
@override
_WindowBodyState createState() => _WindowBodyState();
}
そして_WindowBodyState
という新たな型が出現しましたが、これに以下のようにState<WindowBody>
というクラスが継承されています。見る限り、<……>
の部分に指定されたクラスのStateをこうやって作り出せるのではないかと推測しています。
class _WindowBodyState extends State<WindowBody> {
//
}
ということでボタンやテキストの操作をこのclassに追記していきます。
#####画面要素の構成
最初にState<>
から継承されるbuild()
メソッドを書いていきます。
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(50.0),
child: Column(
children: <Widget>[
Text((_status ? "録音中" : "録音待機"),
style: TextStyle(
color: Colors.greenAccent,
fontSize: 40.0,
fontWeight: FontWeight.w600,
fontFamily: "RondeB",
)),
TextButton(
onPressed: _handlePressed,
style: TextButton.styleFrom(
primary: Colors.blue,
backgroundColor: Colors.tealAccent,
shadowColor: Colors.teal,
elevation: 5,
),
child: Text("更新",
style: TextStyle(color: Colors.white, fontSize: 20.0)))
],
));
}
初めて出てくる記述が大量にありますがひとつひとつ確認しましょう。
まずはContainer(……)
。これも基礎的なWidgetのひとつで、高さや幅の指定、色の指定、PaddingおよびMarginによる余白の指定など様々な機能がある万能君です。特段捻った機能が欲しいわけでもない場合にはこれを使っておけばなんとでもなる気がします。
その中にあるpadding
にはEdgeInsets.****(……)
というメソッドで余白そのもののインスタンスを渡します。この****
にもいくつか種類がありますが、今回はAll(……)
によって、実引数の値で縦横4方向すべてに余白を設定しています。
続いてColumn(……)
は自身のchildrenを縦に並べるレイアウトです。複数のchildがある時は英語らしくchildを複数形のchildrenにするみたいです。
当のchildrenに引き渡す値は<Widget>
となっていますが、原理は解りませんがこの後に[]を使ってWidget
の配列を用意しているように見えます。
その配列内にはText()
がありますが、先ほどタイトルバーで使用したものとは風貌が全く違います。
このText()
というWidgetは第一引数に表示する文字列、第二引数にデザインを指定するyaml形式データを示す形で使用します。先ほどはタイトルバーで使用しただけあってデザインは文字列側で操作するものではないので、第二引数が省略されていたにすぎないようです。
style:
の項目はこれまたTextStyle()
というクラスのインスタンスであり、詳細な文字のデザインが指定されています。見慣れないRondeB
については後述。
配列の次の要素TextButton()
はその名の通り文字の入ったボタンです。
この記事の上の方で取り上げましたチュートリアルサイトのボタンに関するページによれば、文字列入りボタンはFlatButton
というWidgetで作るように記載されています。
ですがFlatButtonはv1.22でDeprecated(非推奨)となりました。
v1.22以降、文字列入りボタンのWidgetはこちらの記事を参考にするとTextButton
が推奨されているので、本記事でもそれを利用します。
このTextButton
というWidgetのonPressed:
要素がボタンを押した際の挙動の指定です。また、タイルの指定はTextButton.styleFrom()
となります。私はこちらのサイトを参考にさせて頂きました。
ボタンの中に入る文字列はこれまたTextButton
のchild:
にText
Widgetを作成して行うこととなります。
####ボタンの挙動
ここからはボタンの挙動と録音機能を実装します。ここからの記述はすべて_WindowBodyState
クラスの中、かつbuild()
の外です。
まずは録音中かどうかを保持する変数が欲しい所です。この中でしか使わないのでprivate宣言しましょう。
trueが録音中であり、falseは録音待機です。
bool _status = false;
次にボタンへと指定する、ボタンの挙動を書き表した関数を用意します。これもここでしか使わないのでprivateです。
void _handlePressed() {
setState(() {
_status = !_status;
if (_status) {
_startRecording();
} else {
_stopRecording();
// _startPlaying();
}
});
}
録音しているかどうかのフラグをひっくり返し、その値によって録音系の関数を呼び出します。
[tips]
これは失敗談となりますが、setState()はasync化できません。
JavaScriptでのPromiseに当たる概念としてDartにはfutureという概念が存在します。
これは特定の処理が終了するまでプログラムが待機するようにしたい時の記述法ですが、setState()はfutureを使うことを拒否します。ですので例えば
void _handlePressed() {
setState(() async {
_status = !_status;
if (_status) {
// 録音を開始する
bool result = await Record.hasPermission();
final directory = await getApplicationDocumentsDirectory();
String pathToWrite = directory.path;
await Record.start(
path: pathToWrite + "/kari.m4a",
encoder: AudioEncoder.AAC,
bitRate: 128000,
samplingRate: 44100,
);
} else {
_stopRecording();
// _startPlaying();
}
});
}
このように記述したが最後、ボタンを押すたびにAndroid Studioの実行欄にエラーがつらつらと並ぶこととなります。
前述の例のように処理をすべて別個のメソッド化し、setState()
はそれを呼び出すだけにすることでエラーを回避できます。
それでは続けて_startRecording()
メソッドを制作していきます。
####録音を行うメソッド
先ほど取り上げたRecord 2.1.1を用いて録音する方法は、当該ページのReadme->Usage欄に記載されています。
import作業は先に行ったので、それ以降の文を見ていきましょう。
// Check and request permission
bool result = await Record.hasPermission();
// Start recording
await Record.start(
path: 'aFullPath/myFile.m4a', // required
encoder: AudioEncoder.AAC, // by default
bitRate: 128000, // by default
sampleRate: 44100, // by default
);
// Stop recording
await Record.stop();
// Get the state of the recorder
bool isRecording = await Record.isRecording();
- 最初のbool result = ……
- 録音権限があるかどうか確認し、なければユーザに要求する
- await Record.start(……)
- yaml形式で録音ファイルの保存先、エンコーダ、ビットレート、サンプルレートを指定する
- pathについては後述の指定法で
- encoderはAACで良いと思われます。音質劣化がなく、録音ファイルを参照するのはほとんどこのアプリだけだと思われるので……
- bitRateはこの指定だと128kbpsです。そこまで高音質ではないですが、さほど違和感はないと思います。
ちなみに、このパッケージではencoderの指定にmp3対応のものがありませんでした。ビットレート160kbps以上だとencoderをmp3にした方が理論的には高音質になっていきますが、諦めましょう。 - sampleRateは44.1kHzで音楽業界では標準の数値です。このままで良いかなと。
- yaml形式で録音ファイルの保存先、エンコーダ、ビットレート、サンプルレートを指定する
- await Record.stop()
- 録音を停止します。停止側のメソッドを作る時に使えそうなので頭の片隅に。
- bool is Recording = ……
- 恐らく録音中かどうかを返すgetterっぽいメソッドです。
今回は自前でbool変数を用意しているのですが、録音機能側にテキストの表示を厳密に同期させるならこのメソッドを利用した方が良さそうです。
- 恐らく録音中かどうかを返すgetterっぽいメソッドです。
どうやらこれを使えば簡単に録音できそうな……?
void _startRecording() async {
// 録音を開始する
await Record.hasPermission();
await Record.start(
path: "kari.m4a",
encoder: AudioEncoder.AAC,
bitRate: 128000,
samplingRate: 44100,
);
}
最初にRecord.hasPermission();
を呼び出しています。結果をガン無視しているのですが、記事執筆が終わってから要求が拒否されたときの処理を書こうと思っています(おい)。
ということで録音開始!pathはファイル名書いとけば上手いことやってくれるだr…………
真面目に書き込みできるパスを取得する必要がありました。
pathの指定がファイル名だけだとAndroidのルートフォルダを指定していることになってしまい、EROFS
が出ました。
そこで書き込み可能なパスを取得する方法を調べているとこのページにたどり着きました。
曰く、
import 'package:path_provider/path_provider.dart';
をインポートすると利用できるgetApplicationDocumentsDirectory()
というメソッドが、今のアプリケーションから専用でアクセスできるディレクトリのパスを返すそう。これを使ってみましょう。
# (dependencies:)
path_provider: ^2.0.1
import 'package:path_provider/path_provider.dart';
// ~中略~
// class _WindowBodyState extends State<WindowBody>{
void _startRecording() async {
// 録音を開始する
await Record.hasPermission();
final directory = await getApplicationDocumentsDirectory();
String pathToWrite = directory.path;
await Record.start(
path: pathToWrite + "/kari.m4a",
encoder: AudioEncoder.AAC,
bitRate: 128000,
samplingRate: 44100,
);
}
完璧ですね。ほとんどの処理は同期しないと実行が次へ次へとかっ飛ばしてしまうのでawaitを付けまくっています。
次に、アプリから書き込みが許される専用の書き込みディレクトリをgetApplicationDocumentsDirectory()
で取得します。これは型が解らない(多分調べたらある)ので、型名を省略して推論してもらいましょう。
それに対して.path
でString型の絶対パスを取得します。これはStringなのが解っているので型名を指定しています。
最後にRecord.start()
で録音を開始しましょう。pathは「書き込みできるディレクトリ/kari.m4a」と指定しています。なので今の所は最後に録音した音声しか残りません。これも今後確実に変更します。
####録音を停止するメソッド
次に録音を停止するメソッドが必要です。
void _stopRecording() async {
// 録音を停止する
await Record.stop();
}
もはや説明不要かと思われますが、async関数で録音停止待ちをして終了します。これもtry~catchで停止失敗処理をするなど必要だとは思いますが、今のところはこれで通します。
##再生
ここまでで録音するプログラムの作成が完了しました。次に録音した音声を再生してみましょう。
Googleでflutter audio playerと検索するとこれがヒットします。
利用するために追記する部分を以下に纏めます。
# (dependencies:)
audioplayers: ^0.18.3
import 'package:audioplayers/audioplayers.dart';
また、長時間の音源再生などにおいては
<uses-permission android:name="android.permission.WAKE_LOCK" />
を追記する必要があります。
あとはパッケージのReadmeを読みつつ実装。先ほどのsetState()
における_startPlaying()
のコメントを解除しておいてください。
void _startPlaying() async {
// 再生する
AudioPlayer audioPlayer = AudioPlayer();
final directory = await getApplicationDocumentsDirectory();
String pathToWrite = directory.path;
await audioPlayer.play(pathToWrite + "/kari.m4a", isLocal: true);
}
AudioPlayer
のインスタンスを生成し、録音先パスと同じディレクトリの同じファイルを参照してplay()
に渡します。ローカルファイルの再生にはyamlでisLocal: true
を渡す必要アリ。
####確認、テスト
最後に現時点でのプログラムをこちらに記載します。
長いので折り畳み
import 'package:flutter/material.dart';
import 'package:record/record.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:path_provider/path_provider.dart';
void main() {
runApp(RecordScreen());
}
class RecordScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Record',
home: Scaffold(
appBar: AppBar(
title: Text('Record'),
),
body: Center(
child: WindowBody(),
),
),
);
}
}
class WindowBody extends StatefulWidget {
@override
_WindowBodyState createState() => _WindowBodyState();
}
class _WindowBodyState extends State<WindowBody> {
bool _status = false;
void _startRecording() async {
// 録音を開始する
bool result = await Record.hasPermission();
final directory = await getApplicationDocumentsDirectory();
String pathToWrite = directory.path;
await Record.start(
path: pathToWrite + "/kari.m4a",
encoder: AudioEncoder.AAC,
bitRate: 128000,
samplingRate: 44100,
);
}
void _stopRecording() async {
// 録音を停止する
await Record.stop();
}
void _startPlaying() async {
// 再生する
AudioPlayer audioPlayer = AudioPlayer();
final directory = await getApplicationDocumentsDirectory();
String pathToWrite = directory.path;
await audioPlayer.play(pathToWrite + "/kari.m4a", isLocal: true);
}
void _handlePressed() {
setState(() {
_status = !_status;
if (_status) {
_startRecording();
} else {
_stopRecording();
_startPlaying();
}
});
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(50.0),
child: Column(
children: <Widget>[
Text((_status ? "録音中" : "録音待機"),
style: TextStyle(
color: Colors.greenAccent,
fontSize: 40.0,
fontWeight: FontWeight.w600,
fontFamily: "RondeB",
)),
TextButton(
onPressed: _handlePressed,
style: TextButton.styleFrom(
primary: Colors.blue,
backgroundColor: Colors.tealAccent,
shadowColor: Colors.teal,
elevation: 5,
),
child: Text("更新",
style: TextStyle(color: Colors.white, fontSize: 20.0)))
],
));
}
}
# (dependencies:)
record: ^2.1.0+1
audioplayers: ^0.18.3
path_provider: ^2.0.1
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.sound_try">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- 以下略 -->
実際に実機などにビルドしてテストして頂ければと思います。録音と再生がしっかり行われる……はず…………
#あとがき
ぶっちゃけ記事を書いている時間のほうがプログラムを書いている時間より長いです。本当に一瞬で出来てしまいFlutterの良さに気づいています。
次回は過去に録音した音源も保持できるようにして、声をシンセ化するための考察などをしていこうと思います。投稿は4/26辺りだと思います。
それでは次回もよろしくお願いします。