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

【Flutter × Go × ESP32】ミーアにミュート機能を追加する方法

mia-mute-function
この記事は約16分で読めます。

はじめに

方言を話すおしゃべり猫型ロボット『ミーア』を開発中。

https://mia-cat.com

ベータ版をリリースした後、実際に使っていただいた複数のユーザから

「話す頻度に「喋らないモード」が欲しい」

との要望が来たので、今回は本機能の実装を記載。

詳細を伺ったところ、

「オンラインのmeeting中に、いきなり話し始めるとビックリするのと、勉強や仕事などで集中したい時もあるので」

とのこと。

UIUXをどうするか?→ホーム画面にトグルアイコン表示

現在、ミーアの話す頻度の設定は、「ホーム画面→設定タブ→話す頻度」の部分で設定できる。

当初は、ここのプルダウンの選択項目の一つとして「しゃべらない」を追加するか、この画面の下に「しゃべらないモードに切り替える」という文言と、スイッチのトグル表記にしようかと考えていた。

しかし、一緒に開発をしているエンジニアから

「頻度の設定は少しアクセスしにくいので、個人的にはホーム画面のわかりやすい箇所でON/OFFできると良いかな・・?と思いました。現状だと数日に一回くらい電源OFFにする機会があって、一番よく使う機能になるかもしれないです。アレクサだと、音量の横くらいにミュートボタンがありました。」

とコメントをもらったので、試しにアレクサを見てみたところ、確かにホーム画面にミュート(この場合はおやすみモード)がアイコンのトグルで表示されていた。

というわけで、アレクサを参考にして、音量スライダーの下に、ミュート機能という項目を設置して、スイッチのトグルでONOFFを切り替えられるようにしようと思う。デフォルトはOFF。

現状のアプリホーム画面UI

改修後のUI

ミュート機能の改修の一連の流れ

アプリ – サーバー – デバイス間の操作で行うべきこと

アプリ→サーバー→デバイス間で、型変換が現状多数発生しているので、型変換を中心に、実装方針をまとめておく。

アプリ(Dart)

  • DartオブジェクトからProtoBufメッセージへの変換
    • アプリのUIでミュートスイッチの状態を変更し、Dartオブジェクトに反映。
    • DartオブジェクトをProtoBufメッセージに変換。
  • API通信でProtoBufメッセージをサーバーに送信
    • 変換されたProtoBufメッセージをAPI経由でサーバーに送信。

サーバー(Go)

  • ProtoBufメッセージからGo構造体への変換
    • 受け取ったProtoBufメッセージをGo構造体に変換。
  • データベースのカラム値更新
    • Go構造体を用いてデータベースのis_mutedカラムを更新。
  • Go構造体からProtoBufメッセージへの変換
    • 更新されたGo構造体をProtoBufメッセージに変換。
  • ProtoBufメッセージからJSON形式への変換
    • 変換されたProtoBufメッセージをJSON形式に変換。
  • デバイスシャドウのdesiredを更新
    • JSON形式のデータをデバイスシャドウのdesiredに反映。
  • MQTT通信でデバイスに送信
    • デバイスシャドウの更新情報をMQTT通信でデバイスに送信。

デバイス(ESP32)

  • MQTTで受信したJSONデータをC++構造体に変換
    • 受信したJSONデータを解析し、C++構造体に変換。
  • ロジック実行
    • is_mutedfalseの場合にのみ音声と表情の再生を行うロジックを追加。
  • デバイスシャドウのreportedを更新(JSON形式)
    • ロジックの実行結果をデバイスシャドウのreportedに反映。

アプリ(Flutter)

ミュートスイッチと機能の追加(UI修正)

ミュートスイッチの追加

  • アプリのホーム画面にミュート機能を有効にするためのスイッチを追加する。
  • valueプロパティには現在のミュート状態 (isMuted) を渡し、onChangedプロパティにはスイッチが切り替えられたときに呼び出されるコールバック関数 (_onMuteChanged) を設定する。
Dart
// ミュートスイッチをUIに追加
Switch(
  value: isMuted,
  onChanged: _onMuteChanged,
),

ミュート状態の更新

  • サーバーにミュート状態を送信する関数を追加する。
  • スイッチの状態が変わるたびにsetStateを呼び出してUIを更新する。ref.read(userProvider.notifier)を使用してuserNotifierを取得し、updateUserメソッドを呼び出してユーザーのミュート状態を更新する。
Dart
void _onMuteChanged(bool isMuted) {
  setState(() {
    final userNotifier = ref.read(userProvider.notifier);
    userNotifier.updateUser(User(isMuted: isMuted));
  });
}

Dartクラスにis_mutedを追加

  • Userクラスに新しいフィールドisMutedを追加する。このフィールドは、ミュート機能の状態を保持する。
  • UserクラスのコンストラクタでisMutedフィールドを初期化する。
  • fromJsontoJsonメソッドにより、isMutedフィールドをJSON形式との変換に含めるようにする。

lib/api/user.dart

Dart
class User {
  // 既存のフィールド...

  final bool? isMuted; // ミュート機能の追加フィールド

  User({
    // 既存のフィールドの初期化...

    this.isMuted,
  });

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

Protobufメッセージにis_mutedを追加

  • Userメッセージに新しいフィールドis_mutedを追加。このフィールドは、Dartクラスと対応してミュート機能の状態を保持する。
  • フィールド番号は21を使用。他のフィールドと番号が重複しないようにする。

protos/user.proto

Dart
message User {
  // 既存のフィールド...

  bool is_muted = 21; // ミュート機能の追加フィールド
}

プロジェクトのルートディレクトリで下記protocコマンドを実行して、protoファイルからgRPCクライアントコードを生成。生成されたgRPCクライアントコードをもとに、ProtobufメッセージはDart構造体にシリアライズおよびデシリアライズされる。

ShellScript
protoc --dart_out=grpc:lib/grpc -Iprotos protos/user.proto

サーバー(Go)

DB更新:usersテーブルにis_mutedカラムを追加

マイグレーションファイルの作成

新しいマイグレーションファイルを作成してusersテーブルにis_mutedカラムを追加。

ShellScript
-- 20240629000000_add_is_muted_to_users.up.sql
ALTER TABLE users ADD COLUMN is_muted BOOLEAN DEFAULT FALSE;

マイグレーションの実行

ShellScript
migrate -path ./migrations -database ${MYSQL_MIGRATE_DSN} up

User構造体にis_mutedフィールドを追加

user.go

Go
package clocky_be

import (
	"time"

	"github.com/EarEEG-dev/clocky_be/types"
)

type User struct {
	// 既存のフィールド...
	IsMuted                    types.Null[bool]             `db:"is_muted" json:"is_muted"`
}

type UserUpdate struct {
	// 既存のフィールド...
	IsMuted                    types.Null[bool]             `db:"is_muted" json:"is_muted"`
}

ToDeviceConfig関数の修正

ToDeviceConfig関数を使用して、User構造体のデータをProtobufのDeviceConfigメッセージに変換し、さらにJSON形式に変換してデバイスシャドウに反映する。

device_config.go

Go
package clocky_be

import (
	"github.com/EarEEG-dev/clocky_be/pb"
	"github.com/EarEEG-dev/clocky_be/types"
)

func ToDeviceConfig(u *User) pb.DeviceConfig {
	return pb.DeviceConfig{
		// 既存のフィールド...
		IsMuted: u.IsMuted.Ptr(), // ミュート機能の追加フィールド
	}
}

デバイスシャドウの更新

handle_update_user.go

Go
package clocky_be

func (h *UserHandler) HandleUpdateUser(c echo.Context) error {
	uid := c.Get("uid").(string)
	var params UserUpdate
	if err := c.Bind(¶ms); err != nil {
		return err
	}
	// 更新後のユーザー情報を取得する
	updatedUser, err := GetUser(h.db, uid)
	if err != nil {
		return err
	}

	// デバイスIDが存在するとき
	if updatedUser.DeviceID.Valid {
		dc := ToDeviceConfig(updatedUser)
		desired := &pb.PublishShadowDesired{
			State: &pb.ShadowDesiredState{
				Desired: &pb.ShadowDesired{
					Config: &dc,
				},
			},
		}
		// DeviceShadowを反映
		if err = h.sm.UpdateShadow(c.Request().Context(), updatedUser.DeviceID.V, desired); err != nil {
			c.Logger().Errorf("failed to update device shadow: %v", err)
			return echo.NewHTTPError(http.StatusInternalServerError, "failed to update device shadow")
		}
	}
	return c.JSON(http.StatusOK, updatedUser)
}

これで、アプリからミュート機能がオンにされた場合、その状態がサーバーに送信され、デバイスシャドウに反映され、ESP32デバイスに伝えられるようになる。

デバイス(ESP32, C++)

JSONデータの受信と解析

MQTTで受信したJSONデータを解析し、C++のDeviceConfig構造体に変換する。

device_config.cpp

C++
#include "device_config.h"

std::optional<DeviceConfig> readDeviceConfig(const JsonVariant &json, const char *key) {
  if (json.containsKey(key)) {
    DeviceConfig state;
    state.is_muted = read<bool>(json[key], "is_muted");
    return state;
  }
  return std::nullopt;
}

void writeDeviceConfig(const DeviceConfig &config, JsonObject &obj) {
  writeOpt(obj, "is_muted", config.is_muted);
}

表情再生のロジック制御

  • main.cppのloop関数で、DeviceConfig構造体のis_mutedフィールドを使用して、is_mutedfalseの場合にのみ音声と表情の再生を行う。

main.cpp

C++
void loop() {
  if (inSafeMode) {
    safeModeLoop();
    return;
  }

  // ネットワーク接続状態の変化を監視
  monitorWiFiConnectionChange();

  // ネットワーク接続がある場合の処理
  if (isWiFiConnected()) {
    executeWiFiConnectedRoutines();
  }

  // ボタン周りの処理
  buttonManager.handleButtonPress();

  // is_mutedがfalseの場合のみ、音声と表情の再生を行う
  auto desiredConfig = SyncShadow::getInstance().getDesiredConfig();
  if (!desiredConfig || !desiredConfig->is_muted.value_or(false)) {
    ExpressionService::getInstance().render();
  } else {
	  Serial.println("is muted function called"); // デバッグ用
  }

  delay(10);
}

デバイスシャドウのreportedに、is_mutedフィールド変更結果を反映

SyncShadow::updateConfigメソッドを呼び出す際に、変更されたis_mutedフィールドの状態がデバイスシャドウに反映される。これにより、サーバーとデバイスの間でis_mutedの状態が同期される。

main.cpp

C++

// デバイス設定の変更を適用し、デバイスシャドウに通知する
void applyAndReportConfigUpdates() {
  Logger &logger = Logger::getInstance();
  SyncShadow &syncShadow = SyncShadow::getInstance();

  auto desiredConfig = syncShadow.getDesiredConfig();
  auto reportedConfig = syncShadow.getReportedConfig();

  auto optConfigDiff = getDeviceConfigDiff(desiredConfig, reportedConfig);
  // 変更がない場合はスキップ
  if (!optConfigDiff) {
    return;
  }
  logger.debug("device config changed");

  // 変更のあった設定
  auto configDiff = optConfigDiff.value();
  logger.debug("config diff: " + serializeDeviceConfig(configDiff));
// 設定変更のみの場合
  if ( configDiff.is_muted.has_value()) {
    // デバイス設定をデバイスシャドウに通知
    if (auto result = syncShadow.reportConfig(configDiff); !result) {
      logger.error(ErrorCode::CONFIG_REPORT_FAILED, "failed to report config: " + result.error());
      return;
    }
    return;
  }
}

以上の手順により、アプリのUIからデバイスの表情再生まで、ミュート機能を統合することができる。

動作確認

デフォルトではアプリでミュート機能はOFF

アプリのミュート機能をONにした時のログ

サーバー

ShellScript
clocky_api_local  | 2024/06/30 12:04:16 sent message to user: 1
clocky_api_local  | 2024/06/30 12:04:26 sendShadow: userId=1, reported=config:{talk_frequency:15  weather_announcement_time:{hour:8}  birth_date:{year:1988  month:10  day:1}  phrase_type:"standard"  talk_start_time:{hour:7}  talk_end_time:{hour:22}  volume:50  firmware_version:"1.0.3"  is_muted:true}  wakeup_time:25  downloading:{}  connected:true  last_action_time:1719748976  last_periodic_talk_time:1719748971

databaseのUserテーブルのis_mutedカラムの値が0から1に変更されたことを確認。

deviceshadowのdesired→is_mutedがfalseからtrueに変更されたことを確認。

デバイス

ShellScript
21:04:08.670 > MQTTPubSubClient::onMessage: $aws/things/device_id/shadow/update/delta {"version":23,"timestamp":1719749048,"state":{"config":{"is_muted":true}},"metadata":{"config":{"is_muted":{"timestamp":1719749048}}}}
21:13:57.136 > is muted function called

deviceshadowのreported→is_mutedがfalseからtrueに変更されたことを確認。

その後、ミュートモードを解除したら、また目が動き始めて音声もユーザーが設定した話す頻度の間隔で再生されることを確認した。

これで、オンラインミーティング中や勉強中など集中したい時に、アプリからワンタップでミーアをストップできるようになった。

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