はじめに
以前こちらの記事で、「flutter_soundを用いた音声録音再生機能の実装」について記載した。
今回は、続きとして、録音した音声ファイルをAPI通信でアプリからサーバー(AWS S3)にアップロードする部分を実装したいと思う。
API設計
エンドポイント: /api/upload_voice_with_schedule
- メソッド:
POST
- リクエストボディ:
phrase
: フレーズテキスト (string)is_private
: フレーズの公開設定 (bool)time
: 再生時間(オプション、types.HourMinute
)days
: 再生曜日のリスト(オプション、[]string
)file
: 音声ファイル (multipart/form-data)
処理の流れ
- クライアントからのリクエストを受け取る。
- 音声ファイルをS3にアップロードし、
voice_path
を取得。 - フレーズのレコードをデータベースに作成。
- スケジュールが存在する場合は、スケジュールレコードを作成。
サーバー側(Go)
UserPhraseHandler 関数の実装
新しい音声ファイルアップロード用のハンドラHandleUploadVoiceWithSchedule
関数を作成
コンテキストと依存関係の管理
UserPhraseHandler
構造体は、データベース接続(sqlx.DB
)と設定(Config
)を保持する。これにより、APIハンドラーがデータベースや設定にアクセスできるようになる。
音声ファイルの処理
c.FormFile("voice")
を使用して音声ファイルを取得し、file.Open()
でファイルを開く。その後、buf.ReadFrom(src)
でファイルデータをバイトスライスに読み込む。
S3へのアップロード
- 現在の日時を使用して一意のファイル名を生成し、
UploadStreamToS3
関数を使用して音声データをS3にアップロードする。S3バケット名とファイルキーは事前に定義されている。
トランザクションの開始とフレーズの作成
- データベーストランザクションを開始し、
CreateUserPhrase
を呼び出して新しいフレーズをデータベースに作成する。失敗した場合はトランザクションをロールバックする。
音声ファイルパスの更新
- アップロードされた音声ファイルのS3パスをデータベース内のフレーズに関連付ける。ここで、
voice_path
フィールドを更新する。
APIレスポンスの送信
- 正常に完了した場合、HTTPステータス201(Created)とともに新しいフレーズのデータをJSON形式でクライアントに返す。
user_phrase_handler.go
// 音声ファイルをアプリからアップロード
func (h *UserPhraseHandler) HandleUploadVoiceWithSchedule(c echo.Context) error {
phraseText := c.FormValue("phrase")
if phraseText == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Phrase text is required")
}
uid := c.Get("uid").(string)
user, err := GetUser(h.db, uid)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "User not found")
}
file, err := c.FormFile("voice")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Voice file is required")
}
src, err := file.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Unable to open file")
}
defer src.Close()
var buf bytes.Buffer
_, err = buf.ReadFrom(src)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read file")
}
audioData := buf.Bytes()
ctx := context.Background()
timestamp := time.Now().Format("20060102-150405")
fileName := fmt.Sprintf("user_upload_%s.mp3", timestamp)
key := fmt.Sprintf("users/%d/user_phrases/%s", user.ID, fileName)
err = UploadStreamToS3(ctx, "your-bucket-name", key, audioData)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upload to S3")
}
tx, err := h.db.Beginx()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to start transaction")
}
phrase, err := CreateUserPhrase(tx, user.ID, phraseText, true, true)
if err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create phrase")
}
err = UpdateUserPhraseVoicePath(tx, phrase.ID, user.ID, key)
if err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update voice path")
}
var schedule *PhraseSchedule
// スケジュール情報の保存(存在する場合)
var req struct {
Time *types.HourMinute `json:"time,omitempty"`
Days *[]string `json:"days,omitempty"`
}
if err := c.Bind(&req); err == nil {
if req.Time != nil && req.Days != nil {
schedule, err = CreatePhraseSchedule(tx, user.ID, phrase.ID, *req.Time, *req.Days)
if err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create phrase schedule")
}
}
}
if err := tx.Commit(); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to commit transaction")
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"phrase": phrase,
"schedule": schedule,
})
}
APIエンドポイント作成
POST
メソッドを使用して/upload_voice_with_schedule
というパスに対するリクエストを受け付けるルートを定義。先ほど作成した、HandleUploadVoiceWithSchedule関数を呼び出す。
appGroup.POST("/upload_voice_with_schedule", uph.HandleUploadVoiceWithSchedule)
アプリ側(Flutter)
uploadVoiceWithSchedule 関数:音声ファイルと関連情報をサーバーに送信
http.MultipartRequest
の使用
- 音声ファイルを含むデータを送信するために、
http.MultipartRequest
を使用する。このクラスは、ファイルなどのバイナリデータと他のフォームデータを同時に送信するためのもの。 request.fields
で、フォームデータとしてフレーズや公開設定の情報が設定。request.files.add
で、音声ファイル(ローカルファイルのパス)を送信データに追加。
api_client.dart
api_client.dart
はサーバーとの通信を行い、APIリクエストを処理する。
Future<void> uploadVoiceWithSchedule(String phrase, String filePath,
bool isPrivate, HourMinute? time, List<String>? days) async {
final url = Uri.parse('$apiUrl/upload_voice_with_schedule');
final headers = await apiHeaders();
final request = http.MultipartRequest('POST', url)
..headers.addAll(headers)
..fields['phrase'] = phrase
..fields['is_private'] = isPrivate.toString()
..fields['recorded'] = 'true'
..files.add(await http.MultipartFile.fromPath('voice', filePath));
if (time != null) {
request.fields['time'] = jsonEncode(time.toJson());
}
if (days != null && days.isNotEmpty) {
request.fields['days'] = jsonEncode(days);
}
final response = await request.send();
if (response.statusCode != 201) {
final responseBody = await response.stream.bytesToString();
throw Exception('Failed to upload voice and phrase: $responseBody');
}
}
user_phrase_notifier.dart
user_phrase_notifier.dart
はアプリケーションの状態管理を担当し、ユーザーインターフェースに最新のデータを反映させる役割を果たす。
Future<void> uploadVoiceWithSchedule(String phrase, String filePath,
bool isPrivate, HourMinute? time, List<String>? days) async {
try {
await apiClient.uploadVoiceWithSchedule(
phrase, filePath, isPrivate, time, days);
await loadUserPhrases();
} catch (e) {
throw Exception('Failed to upload voice and schedule: $e');
}
}
音声ファイルパスを取得して送信
音声録音を行う「RecordVoiceScreen」から音声ファイルのパスを取得し、「AddPhraseScreen」でそのファイルパスを使用してAPIにデータを送信する。
lib/screens/home/record_voice_screen.dart(音声録音再生画面)
まず、「完了」ボタンを押したときに、音声ファイルパスを「AddPhraseScreen」に渡す。
void _onComplete() {
Navigator.of(context).pop(_recordedFile?.path);
}
lib/screens/home/add_phrase_screen.dart(フレーズ追加画面)
ユーザーが音声ファイルをアップロードした場合、そのファイルのパスが _recordedFilePath
に保存される。
フレーズの保存・更新の処理
- 音声ファイルがある場合は、
uploadVoiceWithSchedule
メソッドを使用して音声ファイルと一緒にフレーズをアップロードする。 - 音声ファイルがない場合は、
addUserPhraseWithSchedule
またはupdateUserPhraseWithSchedule
メソッドを呼び出して、フレーズのみをサーバーに送信する。
// 修正後の _AddPhraseScreenState クラス
class _AddPhraseScreenState extends ConsumerState<AddPhraseScreen> {
late TextEditingController _phraseController;
String _newPhrase = '';
bool _isPublic = true;
HourMinute? _selectedTime;
List<String> _selectedDays = [];
bool _showOptions = false;
String? _errorText;
final int maxPhraseLength = 100;
String? _recordedFilePath; // 音声ファイルパスを保存
// ... (その他のコードはそのまま)
Future<void> _addOrUpdatePhrase() async {
if ((_selectedTime != null && _selectedDays.isEmpty) ||
(_selectedTime == null && _selectedDays.isNotEmpty)) {
setState(() {
_errorText = '再生時間と再生曜日は両方設定するか、両方未設定にしてください。';
});
return;
}
List<String> englishDays = translateDaysToEnglish(_selectedDays);
final userPhraseNotifier = ref.read(userPhraseProvider.notifier);
try {
if (_recordedFilePath != null) {
// 録音ファイルがある場合
await userPhraseNotifier.uploadVoiceWithSchedule(
_newPhrase,
_recordedFilePath!,
!_isPublic, // isPrivate の設定
_selectedTime,
englishDays,
);
} else {
// 音声ファイルがない場合
if (widget.phraseWithSchedule == null) {
await userPhraseNotifier.addUserPhraseWithSchedule(
_newPhrase, false, !_isPublic, _selectedTime, englishDays);
} else {
await userPhraseNotifier.updateUserPhraseWithSchedule(
widget.phraseWithSchedule!.phrase.id,
_newPhrase,
false,
!_isPublic,
_selectedTime,
englishDays,
);
}
}
Navigator.of(context).pop();
} catch (e) {
setState(() {
_errorText = 'フレーズの保存に失敗しました: $e';
});
}
}
Future<void> _navigateToRecordVoiceScreen() async {
final recordedFilePath = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => RecordVoiceScreen()),
);
if (recordedFilePath != null) {
setState(() {
_recordedFilePath = recordedFilePath;
});
}
}
// ... (その他のコードはそのまま)
}
動作確認
アプリで音声録音したのちに、再生ボタンをクリック。
アプリ側では下記のAPIリクエストが送られる
flutter: URL: http://192.168.XX.XXX:8080/app/upload_voice_with_schedule
flutter: Headers: {Content-Type: application/json, Accept: application/json, Authorization: Bearer XXX}
flutter: Fields: {phrase: あいうえお, is_private: false, recorded: true}
flutter: File Path: /var/mobile/Containers/Data/Application/C66FD757-79C2-444E-9B94-BDEDA6FDCE32/Library/Caches/recording.m4a
サーバー側ログ
clocky_api_local | {"time":"2024-08-04T00:23:26.959601213Z","id":"","remote_ip":"192.168.65.1","host":"192.168.10.104:8080","method":"POST","uri":"/app/upload_voice_with_schedule","user_agent":"Dart/3.4 (dart:io)","status":201,"error":"","latency":482183917,"latency_human":"482.183917ms","bytes_in":38182,"bytes_out":239}
DB
voice_pathが格納された
また、該当のAWS S3にアクセスして音声ファイルをダウンロードしたところ、録音した音声が無事再生された。
次は、アップロードした音声をもとにフレーズ内容を解析して、感情IDを割り振る部分を開発。マッピングされた感情ID(expression_id)は、ミーアの喜怒哀楽の目として最終的に音声再生時に表示される。続きは次回。