👻

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

2024/08/04に公開

はじめに

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

https://mia-cat.com/

以前こちらの記事で、「flutter_soundを用いた音声録音再生機能の実装」について記載した。

https://kazulog.fun/others/flutter_sound_upload/

今回は、続きとして、録音した音声ファイルを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 メソッドを呼び出して、フレーズのみをサーバーに送信する。

続きは、こちらで記載しています。
https://kazulog.fun/dev/flutter_sound_aws_s3/

Discussion