🐙
【Flutter】flutter_soundを用いた音声録音再生、アップロード機能の実装
様々な方言を話すおしゃべり猫型ロボット「ミーア」を開発中。
前回こちらの記事で、アプリからミーアに話させたい任意のテキストを入力すると、そのフレーズを音声合成してミーアに話させる機能実装を記載した。
今回は、音声録音再生機能を実装したいと思う。
ユーザーがアプリで録音した音声を、再生スケジュール(任意)と共に再生できるようにする。公開非公開をユーザーが設定できる。音声録音だけではなく、任意の音声ファイル(猫の鳴き声や推し活のアイドルの声など)をアップロードできるようにする
アプリ
- 音声録音パッケージ(flutter_sound)を利用:https://pub.dev/packages/flutter_sound
- 音声を録音した後、_filePath(ローカスストレージ)に保存。その後、サーバーにアップロード
サーバー
- 音声データをサーバーに送信する API リクエストを作成(HandleUploadVoice)
- 音声データを S3 にアップロードして voice_path をレコードに追加。
今回の記事では、アプリ側で音声を録音再生する部分までを記載する。
音声許可をinfo.plistとPodFileに追加
今回、音声録音機能で利用するのは、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 {
_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;
void initState() {
super.initState();
_initializeRecorder();
_initializePlayer();
}
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);
}
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),
),
),
],
),
),
);
}
}
続きは、こちらで記載しています。
Discussion