方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【flutter_sound × Go】音声ファイルをAPI経由でAWS S3にアップロード

flutter-sound-aws-s3
この記事は約18分で読めます。

はじめに

様々な方言を話すおしゃべり猫型ロボット「ミーア」を開発中。

ミーア
おしゃべり猫型ロボット「ミーア」は、100以上の種類の豊かな表情で、悲しみや喜びを共有します。様々な性格(皮肉・おせっかい・ロマンチスト・天然)や方言(大阪弁・博多弁・鹿児島弁・広島弁)も話してくれます。ミーアとの暮らしで、毎日の生活をもっ...

以前こちらの記事で、「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

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関数を呼び出す。

Go
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リクエストを処理する。

Dart

  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 はアプリケーションの状態管理を担当し、ユーザーインターフェースに最新のデータを反映させる役割を果たす。

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」に渡す。

Dart
void _onComplete() {
  Navigator.of(context).pop(_recordedFile?.path);
}

lib/screens/home/add_phrase_screen.dart(フレーズ追加画面)

ユーザーが音声ファイルをアップロードした場合、そのファイルのパスが _recordedFilePath に保存される。

フレーズの保存・更新の処理

  • 音声ファイルがある場合は、uploadVoiceWithSchedule メソッドを使用して音声ファイルと一緒にフレーズをアップロードする。
  • 音声ファイルがない場合は、addUserPhraseWithSchedule または updateUserPhraseWithSchedule メソッドを呼び出して、フレーズのみをサーバーに送信する。
Dart
// 修正後の _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リクエストが送られる

ShellScript
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

サーバー側ログ

ShellScript
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)は、ミーアの喜怒哀楽の目として最終的に音声再生時に表示される。続きは次回。

タイトルとURLをコピーしました