はじめに
ベータ版をリリースした後、実際に使っていただいた複数のユーザから
「話す頻度に「喋らないモード」が欲しい」
との要望が来たので、今回は本機能の実装を記載。
詳細を伺ったところ、
「オンラインの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構造体を用いてデータベースの
- Go構造体からProtoBufメッセージへの変換
- 更新されたGo構造体をProtoBufメッセージに変換。
- ProtoBufメッセージからJSON形式への変換
- 変換されたProtoBufメッセージをJSON形式に変換。
- デバイスシャドウのdesiredを更新
- JSON形式のデータをデバイスシャドウのdesiredに反映。
- MQTT通信でデバイスに送信
- デバイスシャドウの更新情報をMQTT通信でデバイスに送信。
デバイス(ESP32)
- MQTTで受信したJSONデータをC++構造体に変換
- 受信したJSONデータを解析し、C++構造体に変換。
- ロジック実行
is_muted
がfalse
の場合にのみ音声と表情の再生を行うロジックを追加。
- デバイスシャドウのreportedを更新(JSON形式)
- ロジックの実行結果をデバイスシャドウのreportedに反映。
アプリ(Flutter)
ミュートスイッチと機能の追加(UI修正)
ミュートスイッチの追加
- アプリのホーム画面にミュート機能を有効にするためのスイッチを追加する。
value
プロパティには現在のミュート状態 (isMuted
) を渡し、onChanged
プロパティにはスイッチが切り替えられたときに呼び出されるコールバック関数 (_onMuteChanged
) を設定する。
// ミュートスイッチをUIに追加
Switch(
value: isMuted,
onChanged: _onMuteChanged,
),
ミュート状態の更新
- サーバーにミュート状態を送信する関数を追加する。
- スイッチの状態が変わるたびに
setState
を呼び出してUIを更新する。ref.read(userProvider.notifier)
を使用してuserNotifier
を取得し、updateUser
メソッドを呼び出してユーザーのミュート状態を更新する。
void _onMuteChanged(bool isMuted) {
setState(() {
final userNotifier = ref.read(userProvider.notifier);
userNotifier.updateUser(User(isMuted: isMuted));
});
}
Dartクラスにis_mutedを追加
User
クラスに新しいフィールドisMuted
を追加する。このフィールドは、ミュート機能の状態を保持する。User
クラスのコンストラクタでisMuted
フィールドを初期化する。fromJson
とtoJson
メソッドにより、isMuted
フィールドをJSON形式との変換に含めるようにする。
lib/api/user.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
message User {
// 既存のフィールド...
bool is_muted = 21; // ミュート機能の追加フィールド
}
プロジェクトのルートディレクトリで下記protocコマンドを実行して、protoファイルからgRPCクライアントコードを生成。生成されたgRPCクライアントコードをもとに、ProtobufメッセージはDart構造体にシリアライズおよびデシリアライズされる。
protoc --dart_out=grpc:lib/grpc -Iprotos protos/user.proto
サーバー(Go)
DB更新:usersテーブルにis_mutedカラムを追加
マイグレーションファイルの作成
新しいマイグレーションファイルを作成してusers
テーブルにis_muted
カラムを追加。
-- 20240629000000_add_is_muted_to_users.up.sql
ALTER TABLE users ADD COLUMN is_muted BOOLEAN DEFAULT FALSE;
マイグレーションの実行
migrate -path ./migrations -database ${MYSQL_MIGRATE_DSN} up
User構造体にis_mutedフィールドを追加
user.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
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
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
#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_muted
がfalse
の場合にのみ音声と表情の再生を行う。
main.cpp
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
// デバイス設定の変更を適用し、デバイスシャドウに通知する
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にした時のログ
サーバー
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に変更されたことを確認。
デバイス
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に変更されたことを確認。
その後、ミュートモードを解除したら、また目が動き始めて音声もユーザーが設定した話す頻度の間隔で再生されることを確認した。
これで、オンラインミーティング中や勉強中など集中したい時に、アプリからワンタップでミーアをストップできるようになった。