はじめに
前回こちらの記事で、アプリからミーアに話させたい任意のテキストを入力すると、そのフレーズを音声合成してミーアに話させる機能実装を記載した。
今回は、音声録音再生機能を実装したいと思う。
ユーザーがアプリで録音した音声を、再生スケジュール(任意)と共に再生できるようにする。公開非公開をユーザーが設定できる。音声録音だけではなく、任意の音声ファイル(猫の鳴き声や推し活のアイドルの声など)をアップロードできるようにする
アプリ
- 音声録音パッケージ(flutter_sound)を利用:https://pub.dev/packages/flutter_sound
- 音声を録音した後、_filePath(ローカスストレージ)に保存。その後、サーバーにアップロード
サーバー
- 音声データをサーバーに送信する API リクエストを作成(HandleUploadVoice)
- 音声データを S3 にアップロードして voice_path をレコードに追加。
今回の記事では、アプリ側で音声を録音再生する部分までを記載する。
音声許可をinfo.plistとPodFileに追加
今回、音声録音機能で利用するのは、flutter_soundパッケージ。
https://pub.dev/packages/flutter_sound
flutter_soundパッケージを使って音声録音機能を利用するには、XCodeでマイクへのアクセス許可をリクエストする必要がある。
ios/Runner/info.plitにマイク許可のkeyとvalueを追加
<key>NSMicrophoneUsageDescription</key>
<string>音声入力機能を使用するためにマイクへのアクセスが必要です。</string>
これで大丈夫と思い、ビルドしたところ、下記エラーが発生。
マイクへのアクセス許可を試みたが、許可が降りなかったとエラー表示されている。
[ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Instance of 'RecordingPermissionException'
#0 _AddPhraseScreenState._initializeRecorder (package:clocky_app/screens/home/add_phrase_screen.dart:119:7)
<asynchronous suspension>
リサーチしたところ、さらに、PodFileにPERMISSION_MICROPHONE=1
を追加する必要があるとのこと。
ios/Podfile
post_install do |installer|
installer.pods_project.targets.each do |target|
# Start of the permission_handler configuration
target.build_configurations.each do |config|
# Preprocessor definitions can be found in: https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
## dart: PermissionGroup.microphone
'PERMISSION_MICROPHONE=1',
]
end
end
end
ここまで設定してビルドすると、音声録音画面を表示しようとした際に、マイク許可画面が表示された。
音声録音再生機能の実装
flutter_soundのsimple_recorder.dartのexampleを参考に実装していく。
実際の音声録音再生機能がこちら。
録音開始 (_startRecording) と 録音停止 (_stopRecording)
- 録音を開始すると、指定されたファイル(例:
recording.m4a
)に音声データを保存する。 - 録音停止時には、録音を終了し、ファイルのパスをコンソールに出力。
audio_sessionとflutter_sound_platform_interfaceを追加インポート
audio_session
: 音声の再生や録音の設定を管理するためのパッケージです。例えば、録音中に他の音が鳴らないようにする設定などを行う。flutter_sound_platform_interface
:flutter_sound
ライブラリのバックエンドで使われるインターフェース。音声の録音・再生の機能をサポート。
AudioSession の設定部分
この部分では、音声セッションの設定を行っている。具体的には
AVAudioSessionCategory.playAndRecord
: 録音と再生の両方を可能にする設定。allowBluetooth
とdefaultToSpeaker
: Bluetoothヘッドセットやデバイスのスピーカーを利用するためのオプション。Bluetoothヘッドセットやデバイスのスピーカーを使用して音声を録音する場合に対応avAudioSessionMode.spokenAudio
: 会話用の音声モードを設定。会話の音声を録音したい場合に対応androidAudioAttributes
: Androidデバイスでの音声の属性を設定
import 'dart:io';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sound/flutter_sound.dart';
import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordVoiceScreen extends StatefulWidget {
@override
_RecordVoiceScreenState createState() => _RecordVoiceScreenState();
}
class _RecordVoiceScreenState extends State<RecordVoiceScreen> {
FlutterSoundRecorder? _recorder;
FlutterSoundPlayer? _player;
bool _isRecording = false;
bool _isPlaying = false;
bool _recorderInitialized = false;
bool _playerInitialized = false;
File? _recordedFile;
String? _errorText;
final Codec _codec = Codec.aacMP4;
@override
void initState() {
super.initState();
_initializeRecorder();
_initializePlayer();
}
@override
void dispose() {
_recorder?.closeRecorder();
_player?.closePlayer();
super.dispose();
}
Future<void> _initializeRecorder() async {
_recorder = FlutterSoundRecorder();
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
setState(() {
_errorText = "マイクの使用許可がありません。";
});
throw RecordingPermissionException("Microphone permission not granted");
}
try {
await _recorder!.openRecorder();
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.spokenAudio,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
));
setState(() {
_recorderInitialized = true;
});
} catch (e) {
setState(() {
_errorText = "レコーダーの初期化に失敗しました: $e";
});
}
}
Future<void> _initializePlayer() async {
_player = FlutterSoundPlayer();
try {
await _player!.openPlayer();
setState(() {
_playerInitialized = true;
});
} catch (e) {
setState(() {
_errorText = "プレイヤーの初期化に失敗しました: $e";
});
}
}
Future<void> _startRecording() async {
if (!_recorderInitialized) return;
final directory = await getTemporaryDirectory();
_recordedFile = File('${directory.path}/recording.m4a');
await _recorder!.startRecorder(
toFile: _recordedFile!.path,
codec: _codec,
audioSource: AudioSource.microphone,
);
setState(() {
_isRecording = true;
});
}
Future<void> _stopRecording() async {
if (!_recorderInitialized) return;
await _recorder!.stopRecorder();
setState(() {
_isRecording = false;
});
print('Recorded file path: ${_recordedFile!.path}');
}
Future<void> _playRecording() async {
if (!_playerInitialized || _recordedFile == null) return;
await _player!.startPlayer(
fromURI: _recordedFile!.path,
codec: _codec,
whenFinished: () {
setState(() {
_isPlaying = false;
});
},
);
setState(() {
_isPlaying = true;
});
}
Future<void> _stopPlaying() async {
if (!_playerInitialized) return;
await _player!.stopPlayer();
setState(() {
_isPlaying = false;
});
}
void _onComplete() {
Navigator.of(context).pop(_recordedFile?.path);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('音声入力'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _isRecording ? null : _startRecording,
child: const Text('録音開始'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: _isRecording ? _stopRecording : null,
child: const Text('録音停止'),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _isPlaying ? _stopPlaying : _playRecording,
child: Text(_isPlaying ? '再生停止' : '再生'),
),
],
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _recordedFile == null ? null : _onComplete,
child: const Text('完了'),
),
if (_errorText != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_errorText!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
);
}
}
iOSのボイスメモのようなUIに変更
今のUIだと簡素すぎるので、iPhoneのボイスメモのようなUIに変更したいと思う。
- 録音ボタンのデザイン: 中央に配置された大きな赤い丸いボタン。
- タイムラベル: 録音時間や再生時間を表示するテキスト。
- 再生ボタン: 録音した音声を再生するボタン。
- 波形表示: 録音中の音声波形のリアルタイム表示。
音声入力のUIUX
- 画面を開いたときには録音ボタン(マイクアイコン)のみが表示される。
- 録音が開始されると、録音停止ボタンに切り替わる。
- 録音が停止されると、再生ボタンが表示され、再生中は停止ボタンに切り替わる。
- 録音や再生が完了すると、完了ボタンが表示される。
import 'dart:async';
import 'dart:io';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sound/flutter_sound.dart';
import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordVoiceScreen extends StatefulWidget {
@override
_RecordVoiceScreenState createState() => _RecordVoiceScreenState();
}
class _RecordVoiceScreenState extends State<RecordVoiceScreen> {
FlutterSoundRecorder? _recorder;
FlutterSoundPlayer? _player;
bool _isRecording = false;
bool _isPlaying = false;
bool _recorderInitialized = false;
bool _playerInitialized = false;
File? _recordedFile;
String? _errorText;
final Codec _codec = Codec.aacMP4;
Timer? _timer;
Duration _duration = Duration.zero;
@override
void initState() {
super.initState();
_initializeRecorder();
_initializePlayer();
}
@override
void dispose() {
_timer?.cancel();
_recorder?.closeRecorder();
_player?.closePlayer();
super.dispose();
}
Future<void> _initializeRecorder() async {
_recorder = FlutterSoundRecorder();
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
setState(() {
_errorText = "マイクの使用許可がありません。";
});
throw RecordingPermissionException("Microphone permission not granted");
}
try {
await _recorder!.openRecorder();
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.spokenAudio,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
));
setState(() {
_recorderInitialized = true;
});
} catch (e) {
setState(() {
_errorText = "レコーダーの初期化に失敗しました: $e";
});
}
}
Future<void> _initializePlayer() async {
_player = FlutterSoundPlayer();
try {
await _player!.openPlayer();
setState(() {
_playerInitialized = true;
});
} catch (e) {
setState(() {
_errorText = "プレイヤーの初期化に失敗しました: $e";
});
}
}
Future<void> _startRecording() async {
if (!_recorderInitialized) return;
final directory = await getTemporaryDirectory();
_recordedFile = File('${directory.path}/recording.m4a');
await _recorder!.startRecorder(
toFile: _recordedFile!.path,
codec: _codec,
audioSource: AudioSource.microphone,
);
setState(() {
_isRecording = true;
_duration = Duration.zero;
});
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
setState(() {
_duration += Duration(seconds: 1);
});
});
}
Future<void> _stopRecording() async {
if (!_recorderInitialized) return;
await _recorder!.stopRecorder();
_timer?.cancel();
setState(() {
_isRecording = false;
});
print('Recorded file path: ${_recordedFile!.path}');
}
Future<void> _playRecording() async {
if (!_playerInitialized || _recordedFile == null) return;
await _player!.startPlayer(
fromURI: _recordedFile!.path,
codec: _codec,
whenFinished: () {
setState(() {
_isPlaying = false;
});
},
);
setState(() {
_isPlaying = true;
});
}
Future<void> _stopPlaying() async {
if (!_playerInitialized) return;
await _player!.stopPlayer();
setState(() {
_isPlaying = false;
});
}
void _onComplete() {
Navigator.of(context).pop(_recordedFile?.path);
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, "0");
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
return "$twoDigitMinutes:$twoDigitSeconds";
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('音声入力'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_formatDuration(_duration),
style: TextStyle(fontSize: 48.0, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isPlaying)
IconButton(
icon: Icon(_isRecording ? Icons.stop : Icons.mic),
iconSize: 64.0,
onPressed: _isRecording ? _stopRecording : _startRecording,
color: Colors.red,
),
if (!_isRecording && _recordedFile != null)
IconButton(
icon: Icon(_isPlaying ? Icons.stop : Icons.play_arrow),
iconSize: 64.0,
onPressed: _isPlaying ? _stopPlaying : _playRecording,
color: Colors.green,
),
],
),
const SizedBox(height: 20),
if (_recordedFile != null && !_isRecording && !_isPlaying)
ElevatedButton(
onPressed: _onComplete,
child: const Text('完了'),
),
if (_errorText != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_errorText!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
);
}
}
下記のような音声録音再生画面になった。
音声ファイルアップロードに対応
実際には、新しい音声を録音するだけでなく、既存の音声ファイルをアップロードしたいというニーズもあると思うので、音声ファイルアップロードにも対応できるようにする必要がある。
ファイルアップロード機能の追加
file_picker
パッケージを使用して、ユーザーが音声ファイルを選択できるようにする。
https://pub.dev/packages/file_picker
allowedExtensions
で許可する拡張子を制限する(mp3
, wav
, m4a
, aac
)。
UIの修正:アップロードボタンを追加し、ユーザーがファイルを選択してアップロードできるようにする。
音声ファイルアップロード機能を追加したコードがこちら。
import 'dart:async';
import 'dart:io';
import 'package:audio_session/audio_session.dart';
import 'package:clocky_app/widgets/buttons.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_sound/flutter_sound.dart';
import 'package:flutter_sound_platform_interface/flutter_sound_recorder_platform_interface.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordVoiceScreen extends StatefulWidget {
@override
_RecordVoiceScreenState createState() => _RecordVoiceScreenState();
}
class _RecordVoiceScreenState extends State<RecordVoiceScreen> {
FlutterSoundRecorder? _recorder;
FlutterSoundPlayer? _player;
bool _isRecording = false;
bool _isPlaying = false;
bool _recorderInitialized = false;
bool _playerInitialized = false;
File? _recordedFile;
String? _errorText;
final Codec _codec = Codec.aacMP4;
Timer? _timer;
Duration _duration = Duration.zero;
@override
void initState() {
super.initState();
_initializeRecorder();
_initializePlayer();
}
@override
void dispose() {
_timer?.cancel();
_recorder?.closeRecorder();
_player?.closePlayer();
super.dispose();
}
Future<void> _initializeRecorder() async {
_recorder = FlutterSoundRecorder();
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
setState(() {
_errorText = "マイクの使用許可がありません。";
});
throw RecordingPermissionException("Microphone permission not granted");
}
try {
await _recorder!.openRecorder();
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.spokenAudio,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
));
setState(() {
_recorderInitialized = true;
});
} catch (e) {
setState(() {
_errorText = "レコーダーの初期化に失敗しました: $e";
});
}
}
Future<void> _initializePlayer() async {
_player = FlutterSoundPlayer();
try {
await _player!.openPlayer();
setState(() {
_playerInitialized = true;
});
} catch (e) {
setState(() {
_errorText = "プレイヤーの初期化に失敗しました: $e";
});
}
}
Future<void> _startRecording() async {
if (!_recorderInitialized) return;
final directory = await getTemporaryDirectory();
_recordedFile = File('${directory.path}/recording.m4a');
await _recorder!.startRecorder(
toFile: _recordedFile!.path,
codec: _codec,
audioSource: AudioSource.microphone,
);
setState(() {
_isRecording = true;
_duration = Duration.zero;
});
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
_duration += const Duration(seconds: 1);
});
});
}
Future<void> _stopRecording() async {
if (!_recorderInitialized) return;
await _recorder!.stopRecorder();
_timer?.cancel();
setState(() {
_isRecording = false;
});
print('Recorded file path: ${_recordedFile!.path}');
}
Future<void> _playRecording() async {
if (!_playerInitialized || _recordedFile == null) return;
await _player!.startPlayer(
fromURI: _recordedFile!.path,
codec: _codec,
whenFinished: () {
setState(() {
_isPlaying = false;
});
},
);
setState(() {
_isPlaying = true;
});
}
Future<void> _stopPlaying() async {
if (!_playerInitialized) return;
await _player!.stopPlayer();
setState(() {
_isPlaying = false;
});
}
Future<void> _uploadAudioFile() async {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['mp3', 'wav', 'm4a', 'aac'],
);
if (result != null) {
setState(() {
_recordedFile = File(result.files.single.path!);
_errorText = null;
});
print('Uploaded file path: ${_recordedFile!.path}');
} else {
setState(() {
_errorText = "ファイル選択がキャンセルされました。";
});
}
}
void _onComplete() {
Navigator.of(context).pop(_recordedFile?.path);
}
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, "0");
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
return "$twoDigitMinutes:$twoDigitSeconds";
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('音声入力'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_formatDuration(_duration),
style:
const TextStyle(fontSize: 48.0, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isPlaying)
IconButton(
icon: Icon(_isRecording ? Icons.stop : Icons.mic),
iconSize: 64.0,
onPressed: _isRecording ? _stopRecording : _startRecording,
color: Colors.red,
),
if (!_isRecording && _recordedFile != null)
IconButton(
icon: Icon(_isPlaying ? Icons.stop : Icons.play_arrow),
iconSize: 64.0,
onPressed: _isPlaying ? _stopPlaying : _playRecording,
color: Colors.green,
),
],
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _uploadAudioFile,
child: const Text(
'音声ファイルをアップロード',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 20),
if (_recordedFile != null && !_isRecording && !_isPlaying)
AppButton(
text: '完了',
onPressed: _onComplete,
),
if (_errorText != null)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
_errorText!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
);
}
}
ユーザーは、1画面で1つの音声ファイル(録音済みまたはアップロード済み)のみを完了(保存)することが可能。つまり、ユーザーは録音した音声ファイルまたは既存の音声ファイルのどちらか一方を選択して完了できるが、両方を同時に完了することはできない。
まとめ
flutter_sound, file_pickerパッケージを利用して、flutterアプリでユーザーが音声録音再生、音声ファイルアップロードできるようにできた。
次は、音声入力を完了した時に、取得しているファイルパスを通じて、サーバー側(AWS S3)に音声をアップロードする処理を作る必要がある。続きは次回。