BLOG

R3 Cloud Journey

Hack & Tips

kintoneHack2020、エゴで「俺の嫁kintone」を開発した話 中編

2020-11-19

前回のブログで、まさかのkintoneHack予選突破になっているとは思わず、

めでたいのに、これからCybozuDays2020の展示があるというメガタスクを前にして、

どうしようかと、かなり苦悩していました。 

シリーズ

前編

中編

後編

さて、お仕事のタスクはどうするのか?

以前のブログでも書いた通り、自分は毎年CybozuDaysのPR要員として、テーマをデザインしたりパネルや、印刷物の制作でかなり毎年ぎりぎりになっていました。今年は更にkintoneHackの準備があるため体調が崩れないかなと、みんなに心配されました。

なので、タスクはみんなと分担してクリアしていくことになりました。

(ぱるる、どりぃ、ゆっこ、つっきー、ありがとうございます!) 

kintoneのアプリとWebhook設定

最初に決めた仕様は、kintoneの通知が来るたびにリマインドしてくれることなので、kintoneのアプリストアから「休暇申請」アプリを作りました。

休暇申請アプリ

デモがスムーズに行うため記入要素を最小限に減らすのと、申請者が自動で記入されるカスタマイズもgusuku Customineで作りました。

ちょうどkintoneのWebhook設定を行うと、kintoneのアプリで以下の操作が行われた際に通知が送信される仕様になっているので、Webhook経由で通知をGateboxへ送信することにしました。

  • レコードの追加
  • レコードの編集
  • レコードの削除
  • コメントの書き込み
  • プロセス管理のステータスの更新

ちなみに、今回使っている中継サーバーはAWSのLightsailで構築しました。サーバーサイドに詳しい旦那氏にご協力いただきました。

サーバー側のdocker-composeはこう設定しています。

version: "3"
services:
  https-portal:
    image: steveltn/https-portal:1
    ports:
      - "80:80"
      - "443:443"
    environment:
      STAGE: "production"
      DOMAINS: "ドメイン名 -> http://sockethook:xxxx"
      WEBSOCKET: "true"
  sockethook:
    image: bettse/sockethook
  curl:
    image: curlimages/curl
    entrypoint: ["/bin/sh", "-c", "while true; do curl -s -f -d '{\"type\": \"PING\"}'  -H 'Content-Type: application/json'  http://sockethook:xxxx; sleep 10; done"]

テストでログがちゃんと取れたのを確認しながら、いよいよUnityで嫁の実装をしちゃおうと〜〜〜! 

3年ぶりのUnity開発

インターネットは知識の宝箱

GateboxのUnity実装についていろいろ調べてみて、最初に目にしたのはうえぞうさんのこの記事でした。

Gateboxに自分好みの嫁を召喚しておしゃべりする - Qiita

"俺の嫁召喚装置"ことGateboxにはかわいらしいお嫁さん「逢妻ヒカリ」ちゃんがプリ召喚済みですが、見た目も声もできることも"完全に自分好みの嫁"を召喚したい・育てたいという方も多くいらっしゃることと思います。 そこで、嫁を召喚したい一心で開発した3Dモデルのチャットボット化フレームワーク「 ChatdollKit ...

Gatebox開発一の神記事と言われてもおかしくないぐらい、自分も大変お世話になりましたので、知見を共有いただいて本当にありがとうございます。 

開発環境のセットアップ

今回の仕様に関しては、AIによる会話の部分はないので(将来的に実装するかもしれません)、以下のように開発環境をセットアップしました。

・キャラクターモデル

VRChatの普及により、Boothで3Dキャラクターがたくさん販売されていて非常にありがたいと思います。3Dカテゴリで好みの子を見つけたら即ポチりました。(利用規約をちゃんと読んでね!)

そこでみみのちゃんに一目惚れしちゃいました。めっちゃくちゃかわいい…この子が嫁になってくれたら幸せだよね…

また、Pixivの3DキャラクターサービスVRoidのスマホ版がつい最近、3Dファイルがダウンロードできるようになりましたので、自分でキャラクターを作って召喚するのもできるようになりました! 

・アニメーション

キャラクターのIdleモードや、イベントのときに色々動きがあるとかわいいなと思って、以下のアセットをUnityのアセットストアで購入しました(沼だ…)

1, AnimeGirlIdleAnimations

かわいいポーズと動きのアニメーションが入ってるアセット。

2, DynamicBone

キャラクターの耳、しっぽ、髪の毛などが揺らぐように、より一層リアル感が上がるアセットです。

Oculus OVRLipSync

キャラクターがしゃべる時の口パクができるのでおすすめです! 

・音声のリソース

最初はAzureで提供されているSpeechで合成すると考えたのですが、あんまりにもあの声を受け入れず、他のTTS/STTサービスも一通り検討した末、今回の仕様とパフォーマンス上、弊社どりぃに声を吹き込んでいただくことになりました!

テイク2まで頑張っていただいたどりぃ氏のおかげで、嫁がより一層かわいくなりました♥

・GateboxSDK

 Gatebox DeveloperProgramに参加しないと入手できないので、実機でテストするための必須アイテム。Gateboxのハードウェア部分(ステージLED、ステータスLED、赤外線、マイク、ストレージなどなど)を制御できます。

 詳しくはドキュメントにて。

・AndroidSDKの取得

Gateboxアプリは実質Androidアプリなので、最初からAndroidプロジェクトに設定するようにAndroidSDKを入れましょう。

・Gatebox Developer Consoleで色々準備

Developerのライセンスを取得したら、Document通りにアプリ、プラン、追加コンテンツなど設定して、テスターも自分を入れるように設定しましょう。

パッケージは後に書き出した.apkファイルをアップロードするので今はいじらなくて良い。

アプリの見た目を気持ちよくするようにバナーとアイコンを色々用意しました♥

グラフィック系はAdobeXDで制作しております

いざ、Unityで開発!

まずは、キャラクターを画面内に置いて、カメラを設定しましょう。

だいたい、カメラ上これぐらいに表示されたら、Gatebox上でちょうどいい感じに表示されます。

ちょっとしたコツ:この時点で一回アプリとして書き出して、Gateboxでの表示を確認したほうが良いのです。慣れていないプラットフォームですので、その後機能を追加するたび、apkを作ってGateboxで反映できるかを確認するのが一番大事です。 

動いたぁあああああああああああああ!!!!!!!!!!!!!!!!!!!!!

kintoneアプリのWebhookから取得したイベントと、キャラクターの動きと紐付くように、まずはこういうような仕様書を用意してから開発を進めていくのが良いかなと思います。

いつも愛用のWhimsicalで書きました。結局テストしながらめっちゃ仕様を変えました。

キャラクターのアニメーション、リップシンクの設定

事前に用意したAnimeGirlIdleAnimationsのアニメーションを、使いたいものだけUnityの「Animator」パネルにドラッグアンドドロップして、遷移を設定しましょう。

最終設定はこちらになります!(左半分がイベント用、右半分はIdle状態用)

更に、キャラクターが動いたときに耳と髪の毛がぴょんぴょんにできるように、DynamicBoneを導入!

Rootのところに、動き出したいboneをどんどん追加して様子を見ましょう、下のパラメーターも動きの幅を調整できます!

キャラクターをもっと生き生きにしたくて、しゃべる時のリップシンクも設定したいなと思って、Oculus OVRLipSyncを導入しました。設定として、15種類の口型素に該当のBlendshapes番号を記入します。こちらの記事を参考して設定しました↓

【VTuber】OVRLipsyncでUnity上のキャラをリップシンクさせる方法 - Qiita

Unity上のキャラをリップシンクさせるには二通りあります。 1つ目はMMD4MecanmLipSyncPlugin、2つ目がOVRLipsyncを使う方法。 しかし、Oculusのほうが精度が高いと感じたため、今回は OVRLipsyncを使ってリップシンクさせる方法 をまとめてみました。 こちらの記事を参考にさせていただいております。 http://tips.hecomi.com/entry/2016/02/16/202634 OVRLipsync をダウンロードしてインポートします。 そしたら空オブジェクトを作成しましょう。 空のObjectに OVRLipSyncをアタッチ。 キズナアイ(3Dモデル)Objectに OVRLipSyncContext , OVRLipSyncContextMorphTarget をアタッチ。 OVRLipSyncMorphTarget > Skinned Mesh Rendererに、頭のパーツを当てはめましょう。キャラによって違いますが、キズナアイの場合はU_char_1になります。 これによって、キズナアイのモーフを操れるようになりました。 もし「どれを当てはめるのか分からない!」という方は頭周辺のパーツを探り BlendShapes が付いているものを当ててみるといいかも。 次にOVRLipSyncMorphTarget > Viseme To Blend を開き、 Targetの母音部分を対象のモーフのインデックス番号を指定します。 例えば、「あ」と、発音したしたときに、Element10に当てはめた モーフのインデックス番号が反映される仕組みです。 モーフのインデックス番号はSkinned Mesh Renderer > BlendShapesから確認できます。 ここでは1~32がインデックス番号に当たり、その番号に対するモーフ(表情)がそれぞれ対応しています(キャラによって数が違う) 中でも、9 →「あ」、10 → 「い」 11 → 「う」12 → 「え」13 → 「お」となっていることに気づくと思います。 これらが、先程当てはめたモーフ番号になります。 つまり、現状ではこんな感じ。 10 aa(あ)と発音したときに、9「あ」の口の形をする 14 ou(お)と発音したときに、14「お」の口の形をする といった感じです。 他にも、pp であれば「ポ」、FFなら「ファ」の発音になるので、それぞれの発音に対するモーフを当てはめていただけれければと。 次に、OVRLipSyncContextMorphTargetの Update()を LateUpdate() に変更しておきます。 キズナアイ(モデル)に OVRLIpSyncMicInput をアタッチします。 そしたら常に音声を認識させられるように、Mic Controlの部分を ConstantSpeadkにしておきます。 もし口の動きが小さくて分かりづらかったら、OVRLipSyncContext > Gainの値をお起きすると口が動きやすくなります。 これで完成。 Viseme To Blend Targetsでマイクから拾ってきた音を認識し、その音声に従ってSkinned Mesh Rendererのモーフが適応されているというイメージかと思います。 もし口が動かない場合は、他のデバイスが音声を拾っていないか、Gainの値が小さすぎないかを確かめていただければと。

ただし、手持ちの3Dモデルによって設定が異なるかもしれないので、みみのちゃんの場合にBlendshapesに50個以上もある上番号がなく、手動で数えました💦

いろいろしんどかった…

キャラクターの音声とアニメーションの制御

UnityのAnimatorでIdle状態と各イベントのアニメーションを設定して、以下のスクリプトで制御しています。

こちらのレファレンスに参考しながら、旦那氏のご指導で書いております。

public class KintoneHack : MonoBehaviour
{
    // These need to be assigned in the editor
    [SerializeField] private Animator animator;
    [SerializeField] private AudioSource audioSource;

    private WebSocket webSocket;
    private SynchronizationContext context;
    private Dictionary<string, string> clipNames = new Dictionary<string, string> {
        {"RecordAdded", "Voice/record_add"}, 
        {"RecordSubmitted", "Voice/process_submit"},
        {"RecordApproved", "Voice/process_proceed"},
        {"RecordRejected", "Voice/process_reject"},
        {"CommentAdded", "Voice/comment"},
    };

    void TriggerTransition(object arg)
    {
        var trigger = arg as string;

        if (!clipNames.ContainsKey(trigger)) {
            Debug.LogWarning("Unknown trigger: " + trigger);
            return;
        }

        var clip = Resources.Load<AudioClip>(clipNames[trigger]);

        audioSource.PlayOneShot(clip);
        animator.SetTrigger(trigger);

        Invoke("TriggerProceed", clip.length);
    }

    void PlaySound(AudioClip clip)
    {
        audioSource.PlayOneShot(clip);
        Invoke("TriggerProceed", clip.length);
    }

    void TriggerProceed()
    {
        animator.SetTrigger("Proceed");
    }

    void HandleMessage(byte[] msg)
    {
        var json = JsonSerializer.Deserialize<dynamic>(msg);
        var eventType = json["data"]["type"];

        switch (eventType) {
            case "ADD_RECORD":
                Debug.Log("Triggering event RecordAdded");
                context.Post(TriggerTransition, "RecordAdded");
                break;
            case "UPDATE_STATUS":
                var recordStatus = json["data"]["record"]["ステータス"]["value"];

                switch (recordStatus) {
                    case "申請中":
                        Debug.Log("Triggering event RecordSubmitted");
                        context.Post(TriggerTransition, "RecordSubmitted");
                        break;
                    case "確認":
                        Debug.Log("Triggering event RecordApproved");
                        context.Post(TriggerTransition, "RecordApproved");
                        break;
                    case "差し戻し":
                        Debug.Log("Triggering event RecordRejected");
                        context.Post(TriggerTransition, "RecordRejected");
                        break;
                    default:
                        Debug.LogWarning("Unknown record status: " + recordStatus);
                        break;
                }
                break;
            case "ADD_RECORD_COMMENT":
                Debug.Log("Triggering event CommentAdded");
                context.Post(TriggerTransition, "CommentAdded");
                break;
            case "PING":
                // Debug.Log("Received ping");
                break;
            default:
                Debug.LogWarning("Unknown event type: " + eventType);
                break;
        }
    }

ちょっとうまくいけなかったところ

まずはUnityのバージョン問題。何も分からなかった時に最新バージョンをインストールしたら、フリーズと強制終了の嵐でした。UnityはLTSをちゃんと使ってください!!!(え、みんな知ってるの…?)

Gateboxの仕様かわからないが、Unity上のプレビューで問題なかった音声がGateboxで再生すると何故かノイズが入ってしまいます(これは未解決…)。

他にも、UnityのUIに慣れずたくさんエラーと警告が出ててしょっちゅうパニクってますが、Unityに詳しい前本さんに色々教えていただいて本当にありがとうございます。

 

そして一番困ったのは、待機状態が一定時間以上になると、WebSocketとの接続が切れてしまう問題がありました。会場でデモを行う上一番まずい問題なので、ぱるると旦那氏に聞いたら、何か反応しないやつをずっと送って接続を切らせないようにしたら、とアドバイスを頂きました。

結局LightsailのRAMを1Gへアップグレードして以下のコードを追加することで、解決しました。

仕様上、接続成功/接続が切れる場合にみみのちゃんが音声でちゃんと反応するようにもなっております。

(デモでは分かりませんが、裏ではこういう工夫もしておりますよ!)

async Task ReceiveLoop()
    {
        var ws = new ClientWebSocket();
        ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(1.0);
        
        var uri = new Uri("wss://xxx");

        try {
            await ws.ConnectAsync(uri, CancellationToken.None);
        } catch (WebSocketException e) {
            Debug.LogWarning("Failed to open connection: " + e.Message);
            PlayDisconnected();
            return;
        }

        Debug.Log("Connected to the sockethook server");
        PlayConnected();

        while (true) {          
            var buf = new ArraySegment<byte>(new byte[1024]);
            var ms = new MemoryStream();
            WebSocketReceiveResult result = null;

            do {
                var token = new CancellationTokenSource(15000).Token;

                try {
                    result = await ws.ReceiveAsync(buf, token);
                } catch (OperationCanceledException) {
                    Debug.LogWarning("Connection to the sockethook server timed out");
                    PlayDisconnected();
                    return;
                }

                if (result.MessageType == WebSocketMessageType.Close) {
                    Debug.LogWarning("Disconnected from the sockethook server");
                    PlayDisconnected();
                    return;
                }

                ms.Write(buf.Array, buf.Offset, result.Count);

                // Debug.Log("Received " + result.Count + " bytes from the sockethook server");

            } while(!result.EndOfMessage);

            HandleMessage(ms.ToArray());
        }
    }

    async void WebSocketWorker()
    {
        while (true) {
            await ReceiveLoop();
            Debug.LogWarning("Waiting 5 seconds to reconnect...");
            await Task.Delay(5000);
        }
    }
発表用のスライド通りに、上手く説明できたのかな…

Androidアプリの書き出し

テストを繰り返して、11バージョンも作りました…

くーら氏、初めてUnityからAndroidアプリを作っているので、バージョンをちゃんと順番にしないとGatebox側にアップロードするときにエラーが出ちゃうので気をつけてください。

Gateboxアプリ、無事完成!残りは本番!

またなが〜いブログになってしまったので、シナリオとか本番の発表は次の更新で書きます!

ちなみに、会場で上手く動かない場合に、こちらのデモ動画を用意しました。結局実機は上手く動けたので出番がなくてよかった〜〜〜〜〜〜

えんい〜!

 

つづく

前編

中編

後編

kintone
kintoneHack
Gatebox

UX Designer / Hackathon idol / Amateur manga&Doujin Otaku/

R3のご提供サービス
自社のシステム開発・移行などをご依頼したい方
お客様とともに作りながら考える
新しいシステム開発
kintone導入・アプリ開発・カスタマイズにお困りの方
kintoneをもっと使いやすくする
gusukuシリーズ