25
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UnityAdvent Calendar 2024

Day 23

Unityで非ゲームアプリをクリーンアーキテクチャを意識して作ってみた話

Last updated at Posted at 2024-12-22

はじめに

普段はクラス設計の話をよくしているのですが、今回はより大局的な「アーキテクチャ設計」の話です。

この記事では「非ゲームアプリケーションを、クリーンアーキテクチャを意識してUnityで作るとどんなアーキテクチャとなるのか」を紹介します。

前回「VoicevoxClientSharp: C#やUnityからVOICEVOXで音声合成するライブラリの紹介」という記事を投稿しました。
「VoicevoxClientSharp」というライブラリをせっかく作ったのだから、それを使ったサンプルアプリケーションを作ってみようと考えました。そしてサンプルアプリを実際に作っている途中で「これVoicevoxClientSharpの使用例で終わらせずに、とことん設計をこだわってみてそれをネタにしたほうが面白いじゃん」と思い、この記事を書くに至りました。

もともとサンプル実装用途だった小さいプロダクトを全力で真面目に設計するとどうなるのか? という内容です。

作ったサンプルアプリケーションについて

app.gif

3.jpg

2.jpg

  • VRMアバターを読み込み、VOICEVOXで発話しながらアバターをリップシンクさせるアプリです
    • REST APIでテキストデータを送り込むことができます。コメントビューアなどと連携して生放送コメントの読み上げなどに使う想定

概要

リポジトリ

MITライセンスです。適当にforkして機能拡張したり、それを公開してもらって構いません。

依存ライブラリ

  • VoicevoxClientSharp
  • UniVRM
  • R3
  • UniTask
  • NuGetForUnity
  • VContainer
  • UnitySimpleFileBrowser

機能要件

今回、VoicevoxClientSharpのサンプル実装が一応の話の起点だったため、次のようなアプリケーションを想定して作りました。

  • Unityで作成するデスクトップ向けアプリケーション
    • 配信者がアバターを画面に表示し、生放送のコメントをアバターに読み上げさせるような状況を想定
      • 自由にローカルのVRMを読み込み表示することができる
      • VOICEVOXを用いてテキストの読み上げができる
      • REST APIを公開し、HTTP経由で外部から読み上げテキストデータを送り込むことができる
      • クロマキー合成ができるように背景色を変更できる
      • カメラの操作が行える

設計思想

規模としてはかなり小さいアプリケーションです。そのため抽象化を挟まずにMonoBehaviourベタ書きで書いてもなんとかなるレベルではあります。
しかしそれだけでは味気ないので次のよう条件を付けて設計・実装することにしました。

  • 将来的に機能拡張があると想定し備える

      • ローカルからVRMを読み込むだけでなく、サーバーから読み込む可能性がある
      • VOICEVOX以外の音声合成ソフトウェアに対応する可能性がある
      • 画面の切り替え・複数アバターの同時展開を行う可能性がある
      • 背景画像を自由に設定できるようにする可能性がある
      • 設定したパラメータを保存できるようにする可能性がある
        • などなど…
  • クリーンアーキテクチャの思想に則る

    • パッケージ間の関係性・レイヤ構造・抽象度・安定度を意識する
  • コア部分はUnityにできるだけ非依存とする

    • GameObjectやMonoBehaviourを意識した実装はコア部分では禁止する
      • ただし、UnityEngineそのものへの依存は禁止しない(理由は後述)

以前Unityにおける設計については過去に語ったことがあるのですが、この定義でいうところの「レベル5」を今回は目指しています。

余談:クリーンアーキテクチャ

話が脱線するので折りたたみ。

端的にいえば、クリーンアーキテクチャとは「クリーンなアーキテクチャを作るための思想」のことを指しています。
具体的なアーキテクチャの構成図があるのではなく、「こういうときはこういう考え方で作ると上手くいくことが多いよ」というテクニックを語っているのがクリーンアーキテクチャです。
なので「クリーンアーキテクチャ指向」って言い方をしたほうがしっくりきますね。

クリーンアーキテクチャについて学習するのであれば、まずは書籍を読むことを推奨します。
またAtsushi Nakamuraさんがまとめてくださった記事もわかりやすくてお勧めです。

ちなみに私なりにクリーンアーキテクチャをまとめると次のようになります。

  • うまくいっているアーキテクチャは本質的に同じテクニックを使っている

    • 安定度・抽象度が高いものを中心に依存を整理せよ
    • データフローと依存関係は分離して考えるべきである
  • クリーンアーキテクチャは「採用する」ものではなく「作っていったら自然とそうなる」ものである

    • 設計・アーキテクチャにおける原則や思想を積み重ねていった結果、帰着するのがクリーンアーキテクチャである
      • 有名な設計に「SOLID原則」があるが、それもクリーンアーキテクチャの土台の1つである
  • パッケージ・レイヤー構成はプロダクト都合にあわせて自由に設計してよい

    • 決まり切ったパッケージ・レイヤー構造は存在しない
      • 本に書いてある同心円状のレイヤー構造をまるっと再現しようとして失敗している人が多いが、本質はそこではない
      • レイヤー構造やパッケージは自由に決めてよい
    • スモールスタートし、プロダクトの成長に合わせて随時アーキテクチャを組み直しても全然よい
      • 最初から完璧なアーキテクチャを作るのではなく「アーキテクチャを育てていく」のも大事

自分はクリーンアーキテクチャを考えながらも、多少それを崩して使うことが多いです。
開発において「キレイな設計やアーキテクチャを維持すること」は目的ではありません。それは「長期的に安定した運用を行う状況を作る」ための手段です。
アーキテクチャのキレイさにこだわって逆に保守しにくい、チームメンバーがついてこれない、運用コストが大きい状態になったら本末転倒です。

クリーンアーキテクチャを神格化するのもよくないので、ほどよく距離を取って使うくらいがちょうどよいと思います。

設計

ではここからが本題。どのようなことを考え、どのような設計をしたのか。
上手くいった部分、上手くいかなかった部分などをまとめていきます。

全体のパッケージ構成

全体のパッケージ構成は次のようになっています。

all_class2.jpg

パッケージはそれぞれがasmdefで分割されています(たとえばCoreパッケージはAvatarSpeaker.Core.asmdefが定義されています)。

それぞれのパッケージの概要は次です。

パッケージ名 責務
Core このアプリにおける中核となるモデル、データ構造、インタフェース定義が配置される。GameObjectやMonoBehaviourをここで意識することは禁止とする。
Core.UnityAdapter CoreでGameObjectやMonoBehaviourを意識することは禁止したが、かといってUnityで作る以上はどうしても意識せざるを得ないので、妥協としてCoreからUnityに強く依存する部分だけを分割したパッケージ。
Infrastructures Core(Core.UnityAdapter)を実装する場所。アプリケーションの具体的な振る舞い方法がここで実装される。
Views Core(Core.UnityAdapter)のUnity上での見た目を提供する場所。GameObjectへバリバリに依存することになる。
UseCases Coreが定義するモデルやインタフェースを組み合わせて一連の手続きを提供するパッケージ。
UIs ユーザーとの情報表示や操作の受付を行う場所。ほぼuGUIの実装場所。
Http REST APIを提供するためのHTTPサーバー実装などが置かれる場所。

また図には登場してませんが、StartUpDIという特殊なパッケージも存在します。

パッケージ名 責務
StartUp アプリケーションの動作の起点となる操作を行う場所。シーン起動時に一瞬だけ稼働してそれ以降は何もしない。
DI DIコンテナへのバインド設定を定義する場所。今回はVContainerを使っているため、LifetimeScopeの定義場所となっている。

モデル定義

アプリケーションを成立させるための登場人物(オブジェクト)を定義する必要があります。今回は次のような定義としました。

モデル名 責務
RoomSpace 「空間」を表す概念。SpeakerRoomCameraはこのRoomSpaceに属することとなる。
Speaker 「発話するアバター」を表す概念。実装上はVRMモデルと紐づいており、VOICEVOXを用いて発話する。
SpeakerCamera 「カメラ」の概念。Speakerを撮影し画面に投影するカメラの位置姿勢などを扱う。

とくに重要な登場人物はこの3つであり、すべてCoreに定義されています。アプリケーションの中核となる概念です。

各パッケージの解説

Core

Coreはこのアプリケーションを動作させる上での中核となる概念・モデル・インタフェースが定義される場所となっています。このアプリケーションにおいてもっとも安定度と抽象度が高い領域となっています。さきほどのSpeakerなどのモデルはここに定義されています。

#nullable enable
using System;
using System.Threading;
using AvatarSpeaker.Core.Models;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine; // UnityEngineへの依存を許す

namespace AvatarSpeaker.Core
{
    /// <summary>
    /// 発話者の概念を表すクラス
    /// </summary>
    public abstract class Speaker : IDisposable, IEquatable<Speaker>
    {
        private readonly ReactiveProperty<IdlePose> _currentIdlePose = new(IdlePose.Pose1);

        private readonly ReactiveProperty<SpeakParameter> _currentSpeakParameter = new(SpeakParameter.Default);

        /// <summary>
        /// 現在のポーズ
        /// </summary>
        public ReadOnlyReactiveProperty<IdlePose> CurrentIdlePose => _currentIdlePose;

        /// <summary>
        /// 現在の発話パラメータ
        /// </summary>
        public ReadOnlyReactiveProperty<SpeakParameter> CurrentSpeakParameter => _currentSpeakParameter;

        /// <summary>
        /// 現在発話中のテキスト
        /// </summary>
        public abstract ReadOnlyReactiveProperty<string> CurrentSpeakingText { get; }

        /// <summary>
        /// SpeakerのID
        /// </summary>
        public abstract string Id { get; }

        /// <summary>
        /// Speakerの顔の位置
        /// </summary>
        public abstract Vector3 FacePosition { get; }

        /// <summary>
        /// 体の前方を表すベクトル
        /// </summary>
        public abstract Vector3 BodyForward { get; }


        /// <summary>
        /// Dispose時に発火するUniTask
        /// </summary>
        public abstract UniTask OnDisposeAsync { get; }


        public void Dispose()
        {
            _currentIdlePose.Dispose();
            _currentSpeakParameter.Dispose();
            OnDisposed();
        }

        public bool Equals(Speaker other)
        {
            if (other is null) return false;

            if (ReferenceEquals(this, other)) return true;

            return Id == other.Id;
        }

        /// <summary>
        /// 現在Speakerが保持する設定値で発話させる
        /// </summary>
        public abstract UniTask SpeakAsync(string text, CancellationToken ct);

        /// <summary>
        /// Speakerに発話させる
        /// </summary>
        public abstract UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct);


        /// <summary>
        /// 現在のポーズを変更する
        /// </summary>
        public void ChangeIdlePose(IdlePose idlePose)
        {
            _currentIdlePose.Value = idlePose;
        }

        /// <summary>
        /// 発話パラメータを変更する
        /// </summary>
        public void ChangeSpeakParameter(SpeakParameter speakParameter)
        {
            // 無効な値の場合は変更しない
            if (!speakParameter.Validate()) return;

            _currentSpeakParameter.Value = speakParameter;
        }

        protected virtual void OnDisposed()
        {
            //
        }
    }
}

他にも、ISpeakerSourceというSpeakerのリソース先を表す概念を用いて、実際のSpeakerを提供するISpeakerProviderなどが定義されています。

using System.Threading;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Core.Interfaces
{
    /// <summary>
    /// Speakerのロード元を表す
    /// </summary>
    public interface ISpeakerSource
    {
        UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct);
    }

    /// <summary>
    /// VisitorパターンでISpeakerSourceを処理するためのインタフェース
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public interface ISpeakerSourceVisitor<T>
    {
        UniTask<T> Visit(LocalSpeakerSource source, CancellationToken ct);
    }

    /// <summary>
    /// ローカルファイルからSpeakerをロードするための実装
    /// </summary>
    public readonly struct LocalSpeakerSource : ISpeakerSource
    {
        public string Path { get; }

        public LocalSpeakerSource(string path)
        {
            Path = path;
        }

        public UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct)
        {
            return visitor.Visit(this, ct);
        }

        public override string ToString()
        {
            return $"{nameof(Path)}: {Path}";
        }
    }
}
using System.Threading;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Core.Interfaces
{
    /// <summary>
    /// Speakerを提供する
    /// </summary>
    public interface ISpeakerProvider
    {
        /// <summary>
        /// SpeakerSourceを元にSpeakerを提供する
        /// </summary>
        UniTask<Speaker> LoadSpeakerAsync(ISpeakerSource source, CancellationToken ct);
    }
}

(Visitorパターンを使っているが、これについてはあとで話します)


そして今回、「CoreがUnityEngineを参照してよいのか?」ですごく悩みました。

UnityEngineから完全に切り離す場合、「このアプリケーションは本質的にはUnity非依存です」という強い意志表示ができます。もし今回のアプリケーションが本当にUnity非依存であるのであれば「CoreをUnityEngine非依存にする」という選択は正しいでしょう。

ですが今回においてはそこまで強い意志表示をするメリットが存在しません。だってUnity以外で実装することはほぼありえないわけですし。また、UnityEngine非依存にするとVector3Quaternionといった構造体すらCoreで扱えなくなってしまいます。これは相当に不便です。

そこで今回は「CoreはUnityEngineを部分的に使ってよい」というルールにしました。「UnityEngineへの依存を完全に禁止するとあんまり旨味がない上に手間だけが増える。あとアドベントカレンダーの執筆が間に合わなくなるから。」というのが理由で妥協しました。

  • ルール

    • Coreが扱っていいUnityEngineのオブジェクトはVector3Quaternionといった「データ構造」程度に留める
    • GameObjectMonoBehaviourへの依存は禁止する(UnityのライフサイクルがCoreに入りこまないように)

Core.UnityAdapter

Core.UnityAdapterは「Coreを装飾し、Unityの概念をCoreモジュールに追加する」という場所です。

Coreは部分的にUnityEngineを触ることができるが、GameObjectMonoBehaviourはアクセスしない」というルールを課しました。これはCoreの要素にUnityのライフサイクルが入りこまないように防御する意味で重要な判断でした。

ですが実装を進めるうちに、CoreGameObjectを知らないとどうしようもないという状況に遭遇しました。

そこで苦肉の策として生まれたのがこのCore.UnityAdapterです。

CoreGameObjectを知らないとどうしようもない」とはどのような状況なのか。
それはInfrastructuresViewsの存在が関係してきます。

今回、VoicevoxClientSharpを使う都合上、InfrastructuresでVRMを展開しInstantiateする必要があります。しかし見た目を管理するViewsでも、この生成したVRMを参照して管理する必要があります。となると、どうにかして同じGameObjectをInfrastructuresからViewsに教える必要があります。この両者のつなぐものはCore.Speakerしかありません。しかしCoreではGameObjectを扱うことは禁止です。 詰みです。 どうにかするしかありません。

いろいろと案を考えては見ました。たとえば次のように「外側に別のパッケージを切ってそこを経由してがんばる」といった方法。

これはボツです。「Core.ObjectId」という存在が謎すぎます。そもそもCoreに置かれるものは「アプリケーションの成立において必須である要素」である必要があります。ですがSpeakerSpeakerとして振る舞うためにこのObjectIdは必須ではありません。実装都合でSpeakerという存在が歪められたように見えてしまい、違和感が強いため却下しました。

そして次に考えたのが、今回採用したUnityAdapterを挟むというアイデアです。

やってることはシンプルで「CoreGameObjectを参照できないなら、GameObjectを参照したい部分だけ切り出せばいいじゃん」というものです。

UnityEngineというフレームワークと事実上結婚はしているもののべったりはしたくない、なので別居婚にすればいいじゃん、という発想です。Core.UnityAdapter自体は非常に薄いパッケージでありロジックなどは一切持っておらず、「Coreを装飾する」以上のことをさせていません。

この方法は(少なくとも今回は)上手くいきました。Coreを汚さずに、事実上GameObjectCoreに持たせたように見える、という点においては成功しています。

それってクリーンアーキテクチャとしてどうなの?と思うかもしれませんが、自分はこれでもよいと考えています。そもそも唯一無二の正解となるアーキテクチャは存在しません。「開発者同士がアーキテクチャの意図を理解し、破綻なく長期運用ができる」のであれば何でもいいと思ってます。

「今回はこれで破綻なく上手く回っている。だから問題ない。」と言い切ってしまいます。

Infrastructures

InfrastructuresCoreの実装を行うパッケージです。Coreに定義されていたインタフェースやモデルの実装を行い、アプリケーションが動作するように肉付けするのがこのパッケージです。

たとえばさきほどCoreの例で出したISpeakerProviderの実装は次のようになっています。

using System;
using System.Threading;
using AvatarSpeaker.Core;
using AvatarSpeaker.Core.Interfaces;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using UniVRM10;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// VOICEVOXを利用したSpeaker実装を提供する
    /// </summary>
    public sealed class VoicevoxSpeakerProvider : ISpeakerProvider, ISpeakerSourceVisitor<Speaker>, IDisposable
    {
        private readonly VoicevoxProvider _voicevoxProvider;

        public VoicevoxSpeakerProvider(VoicevoxProvider voicevoxProvider)
        {
            _voicevoxProvider = voicevoxProvider;
        }

        public void Dispose()
        {
            // do nothing
        }

        public UniTask<Speaker> LoadSpeakerAsync(ISpeakerSource source, CancellationToken ct)
        {
            // VisitorパターンでISpeakerSourceを処理する
            return source.Accept(this, ct);
        }

        /// <summary>
        /// LocalSpeakerSourceに対する実装
        /// </summary>
        public async UniTask<Speaker> Visit(LocalSpeakerSource source, CancellationToken ct)
        {
            // ローカルパスからVRMをロードしてInstantiate
            var vrmInstance = await Vrm10.LoadPathAsync(source.Path, ct: ct);
            
            // LoadPathAsyncでのctの扱い方が不明なので
            // ここでキャンセルされていたら破棄して例外を投げる
            if (ct.IsCancellationRequested)
            {
                Object.Destroy(vrmInstance.gameObject);
                ct.ThrowIfCancellationRequested();
            }
            
            // VoicevoxSpeakerを生成
            var speaker = new VoicevoxSpeaker(vrmInstance, _voicevoxProvider);
            return speaker;
        }
    }
}

VoicevoxSpeakerは今回の一番のキモとなる実装です。「VRM」と「VOICEVOX」を使ってアバターが発話する「Speaker」を実現しています。

using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly Subject<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _speechRegisterSubject =
                new();

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストを投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            _speechRegisterSubject
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .RegisterTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override ReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Observableを非同期処理を行えるQueueとして利用
            _speechRegisterSubject.OnNext((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _speechRegisterSubject.Dispose(true);
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}

Coreが定義した振る舞いを実装するのがこのInfrastructuresの責務です。
ここの実装を差し替えることで、たとえばVOICEVOX以外の音声合成ソフトウェアに対応したり、ローカル以外からのVRMの読み込みにも対応したりと、Coreの実装を変更することなくいろいろと拡張や修正が可能となります。

UseCases

UseCasesは「アプリケーションに対する操作(Coreに対する操作)をまとめ、Easyに扱える形式で下位パッケージに提供すること」を責務としています。

Speakerを生成してRoomSpaceに配置する」という操作を行いたい場合、必要な手続きやルールがありそれを遵守して制御する必要があります。
この手続きをメソッドひとつにまとめ、下位パッケージからは単にメソッドを1つ呼ぶだけで必要な処理が完了することができるのです。

たとえば次のメソッドはRoomSpaceUseCaseに定義されている、「Speakerを生成してRoomSpaceに登録する」というメソッドです。

/// <summary>
/// Speakerを読み込みRoomSpaceに配置する
/// すでにSpeakerが配置されている場合は、そのSpeakerを削除して新しいSpeakerを配置する
/// </summary>
public async UniTask LoadNewSpeakerAsync(ISpeakerSource speakerSource, CancellationToken ct)
{
    // SpeakerをRoomSpaceに配置する
    // すでにSpeakerが配置されている場合は、そのSpeakerを削除して新しいSpeakerを配置する
    var roomSpace = await _roomSpaceProvider.CurrentRoomSpace
        .FirstAsync(x => x != null, ct);

    if (roomSpace == null) return;
    
    // 現在のSpeakerがRoomSpaceに配置されている場合は削除する
    var currentSpeaker = roomSpace.CurrentSpeaker.CurrentValue;
    if (currentSpeaker != null) roomSpace.RemoveSpeaker(currentSpeaker.Id);

    // Speakerをロードする
    var speaker = await _speakerProvider.LoadSpeakerAsync(speakerSource, ct);

    // 新しいSpeakerをRoomSpaceに配置する
    roomSpace.RegisterSpeaker(speaker);

    // カメラを現在のSpeakerの顔にフォーカスする
    _speakerCameraUseCase.FocusOnCurrentSpeakerFace();
}

下位パッケージ、たとえばUIsパッケージからはボタン操作に応じてこのLoadNewSpeakerAsyncを呼び出せばSpeakerの生成が実行できるようになるのです。

CoreはとにかくSimpleに単純に「各モデルのがやるべきこと」のみ集中して定義し、それらを外から扱うときはUseCases経由でEasyに扱う、という思想です。
もし挙動を少し変えたくなったのであれば、まずCoreではなくUseCasesでそれを吸収できるかを考えます。それでも難しいとなった場合にCoreへ手をいれる、という流れになります。

Views

ViewsCoreのモデルをUnityEngineを使って表現する場所です。SpeakerSpeakerCameraGameObjectAnimatorCameraなどに紐づけて扱います。

Viewsの責務は「Coreに定義されたモデルを人間が分かる形で可視化・表現すること」です。Views自体は複雑なロジックをもたず、Coreの状態を反映したり、逆にViewsのできごとをCoreに伝えるのみです。

たとえばCoreに定義されているSpeakerCameraは次のような定義となっています。

using System;
using R3;
using UnityEngine;

namespace AvatarSpeaker.Core
{
    /// <summary>
    /// RoomSpace内でSpeakerを表示するカメラ
    /// </summary>
    public sealed class SpeakerCamera : IDisposable
    {
        /// <summary>
        /// 位置
        /// </summary>
        public ReactiveProperty<Vector3> Position { get; } = new(Vector3.zero);

        /// <summary>
        /// 姿勢
        /// </summary>
        public ReactiveProperty<Quaternion> Rotation { get; } = new(Quaternion.identity);


        public Action OnDispose { get; set; }


        public void Dispose()
        {
            OnDispose?.Invoke();
        }

        /// <summary>
        /// 指定した位置を向く
        /// </summary>
        public void LookAt(Vector3 target)
        {
            var direction = target - Position.Value;
            Rotation.Value = Quaternion.LookRotation(direction);
        }
    }
}

SpeakerCamera自体は「カメラ」の概念を抽象化したものであり、これ自体はピュアなC#クラスです。
これを実際のUnityのCameraコンポーネントと紐づけ、画面上の描画を制御するクラスがSpeakerCameraViewです。

SpeakerCameraView自体はPrefabとなっておりCameraコンポーネントを含んでいます。

SpeakerCameraView.jpg

using AvatarSpeaker.Core;
using R3;
using UnityEngine;

namespace AvatarSpeaker.Views.RoomSpaces
{
    /// <summary>
    /// SpeakerCameraの表示を管理するView
    /// </summary>
    public sealed class SpeakerCameraView : MonoBehaviour
    {
        // カメラコンポーネント
        [SerializeField] private Camera _camera;

        private SpeakerCamera _speakerCamera;

        public Camera Camera => _camera;

        /// <summary>
        /// 初期化
        /// </summary>
        public void Initialize(SpeakerCamera speakerCamera)
        {
            _speakerCamera = speakerCamera;
            _speakerCamera.OnDispose = () => { Destroy(gameObject); };


            // SpeakerCameraの位置と姿勢を実際のカメラに反映する
            _speakerCamera.Position
                .Subscribe(position => transform.position = position).AddTo(this);
            
            _speakerCamera.Rotation
                .Subscribe(rotation => transform.rotation = rotation).AddTo(this);
        }
    }
}

また今回はRoomSpaceViewがシーン上の親となり、RoomSpaceに紐づいたViewはその下に属するようにしています。
SubtitleViewRoomSpaceとは独立しているので外に出ている)

views.jpg

UIs

UIsは「人間からの操作受付や、人間への情報提示を行う場所」です。名前のとおり「ユーザーインタフェース」を担当します。
具体的にはuGUIの実装がここに置かれています。

基本的にModel-View-(Reactive)Presenterパターンを採用しています。

Http

Httpは本アプリケーションが提供する「REST API」を実装する場所です。
HttpServerを起動し、外部からのHTTPリクエストを受け付け、UseCaseを介してCoreを操作します。

たとえば、Speakerに発話させるAPI /api/v1/speakers/current/speak はこのように定義されています。

/// <summary>
/// 発話する
/// </summary>
[Post("/api/v1/speakers/current/speak")]
public async ValueTask SpeakAsync(
    HttpListenerRequest request,
    HttpListenerResponse response,
    CancellationToken ct)
{
    try
    {
        var data = await ReadAsJson<SpeakRequestDto>(request, response);

        if (data.Text == null)
        {
            response.StatusCode = (int)HttpStatusCode.BadRequest;
            return;
        }

        // SpeakerUseCaseを用いて発話命令を送る
        // HTTPレスポンスはすぐに返したいのでForget
        _speakerUseCase
            .SpeakByCurrentSpeakerAsync(data.Text, data.Parameters.ToCore(), default)
            .Forget();

        response.StatusCode = (int)HttpStatusCode.OK;
    }
    catch (Exception)
    {
        response.StatusCode = (int)HttpStatusCode.BadRequest;
    }
}
[DataContract(Name = "SpeakRequest")]
public class SpeakRequestDto
{
    [JsonPropertyName("text")] public string Text { get; set; }
    [JsonPropertyName("Parameters")] public SpeakParametersDto Parameters { get; set; }
}

[DataContract(Name = "SpeakParameters")]
public class SpeakParametersDto
{
    [JsonPropertyName("style")] public int Style { get; set; }
    [JsonPropertyName("speedScale")] public float SpeedScale { get; set; }
    [JsonPropertyName("pitchScale")] public float PitchScale { get; set; }
    [JsonPropertyName("volumeScale")] public float VolumeScale { get; set; }

    public SpeakParameter ToCore()
    {
        return new SpeakParameter(new SpeakStyle(Style, ""), SpeedScale, PitchScale, VolumeScale);
    }
}

DI

DIVContainerLifetimeScopeが定義される場所です。

StartUp

StartUpはアプリケーションの起動処理を実行する場所です。
VContainerRegisterEntryPointとして登録されており、アプリ起動時にRoomSpaceの初期化とHttpServerの起動を実行します。

using AvatarSpeaker.Http.Server;
using AvatarSpeaker.UseCases;
using VContainer.Unity;

namespace AvatarSpeaker.StartUp
{
    /// <summary>
    /// アプリケーションの動作の起点
    /// </summary>
    public sealed class ApplicationStartUp : IStartable
    {
        private readonly HttpServerRunner _httpServerRunner;
        private readonly RoomSpaceUseCase _roomSpaceUseCase;

        public ApplicationStartUp(RoomSpaceUseCase roomSpaceUseCase, HttpServerRunner httpServerRunner)
        {
            _roomSpaceUseCase = roomSpaceUseCase;
            _httpServerRunner = httpServerRunner;
        }

        public void Start()
        {
            // HTTPサーバーを起動する
            _httpServerRunner.Start();

            // 新しいRoomSpaceを作成する
            _roomSpaceUseCase.CreateNewRoomSpace();
        }
    }
}

動作シーケンス

起動~RoomSpaceの初期化

RoomSpaceの生成後にViewを生成

UIを操作してSpeakerをRoomSpaceに追加する

データフロー

人間が操作して実際にアプリケーションへ反映されるまでのデータフローですが、次のようになっています。

dataflow.jpg

すべての操作は最終的にCoreに反映され、そのCoreの変動をViewsが監視し反映するというフローになっています。UIsから直接Viewsを操作することはありません。必ずCoreを介し、Coreの状態変更という形でそれがViewsに反映されるフローとしています。

これはまさにThe Clean Architectureで語られていたデータフローと同じですね。

dataflow2.jpg

各種解説

全体の構成概要は解説したので、続いて細かい部分や要所ごとの説明をします。

CoreとViewsの紐づけ:Binder

ViewsCoreで定義したモデルをUnityの上での「実体」として表現することを責務としています。
基本的にはCoreのモデル定義に対応する形でそのViews定義が置かれています。

  • RoomSpace <-> RoomSpaceView
  • Speaker <-> VrmSpeakerView
  • SpeakerCamera <-> SpeakerCameraView

ViewsはPrefab化されており、Coreの要素の変動に合わせてInstantiate/Destroyされます。
この「Coreの要素に応じてViewsInstantiate/Destroyを行い、Coreの要素とViewsを連携させる」ことを責務とした存在がBinderです。

BinderCoreを監視し、RoomSpaceの生成やSpeakerの生成を待ち受けます。
これらイベントが発生したタイミングで対応したViewsを生成し紐づける処理を行います。

using System;
using System.Threading;
using AvatarSpeaker.Core;
using AvatarSpeaker.Core.Configurations;
using AvatarSpeaker.Core.Interfaces;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Views.RoomSpaces;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using VContainer.Unity;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Views.ViewBinder
{
    /// <summary>
    /// RoomSpace内で生成されたオブジェクトをViewにバインドする
    /// </summary>
    public sealed class RoomSpaceViewBinder : IInitializable, IDisposable
    {
        private readonly IConfigurationRepository _configurationRepository;
        private readonly CancellationTokenSource _cts = new();
        private readonly IRoomSpaceProvider _roomSpaceProvider;
        private readonly RuntimeAnimatorController _speakerAnimatorController;
        private readonly SpeakerCameraView _speakerCameraViewPrefab;
        private readonly SubtitleView _subtitleViewPrefab;
        private readonly UguiRoomSpaceBackgroundView _uguiRoomSpaceBackgroundViewPrefab;

        private RoomSpaceView _currentRoomSpaceView;
        private SubtitleView _subtitleView;

        public RoomSpaceViewBinder(SpeakerCameraView speakerCameraViewPrefab,
            IRoomSpaceProvider roomSpaceProvider,
            UguiRoomSpaceBackgroundView uguiRoomSpaceBackgroundViewPrefab,
            RuntimeAnimatorController speakerAnimatorController,
            SubtitleView subtitleViewPrefab,
            IConfigurationRepository configurationRepository)
        {
            _speakerCameraViewPrefab = speakerCameraViewPrefab;
            _roomSpaceProvider = roomSpaceProvider;
            _uguiRoomSpaceBackgroundViewPrefab = uguiRoomSpaceBackgroundViewPrefab;
            _speakerAnimatorController = speakerAnimatorController;
            _subtitleViewPrefab = subtitleViewPrefab;
            _configurationRepository = configurationRepository;
        }

        public void Dispose()
        {
            _cts.Cancel();
            _cts.Dispose();
        }

        public void Initialize()
        {
            // SubtitleViewは常に1つ存在していてよいのでここで作る
            _subtitleView = Object.Instantiate(_subtitleViewPrefab);

            // RoomSpaceが生成されたらRoomSpaceViewとSpeakerCameraViewを生成
            _roomSpaceProvider.CurrentRoomSpace
                .Where(r => r != null)
                .SubscribeAwait(async (r, ct) => await CreateRoomSpaceViews(r, ct))
                .RegisterTo(_cts.Token);

            // RoomSpaceのSpeakerが変更されたらSpeakerViewを更新
            // ただしここで生成できるものはVrmSpeakerに限る
            _roomSpaceProvider.CurrentRoomSpace
                .Where(r => r != null)
                .SelectMany(r => r.CurrentSpeaker)
                .OfType<Speaker, VrmSpeaker>()
                .Subscribe(speaker =>
                {
                    // Speakerが更新されたとき
                    
                    // SpeakerViewを生成
                    // 実体はInfrastructures.VoicevoxSpeakersで生成されたGameObjectなので、
                    // SpeakerViewはそれをネストして保持するだけの空オブジェクト
                    var speakerViewObject = new GameObject("VrmSpeakerView");
                    var speakerView = speakerViewObject.AddComponent<VrmSpeakerView>();
                    speakerView.SetVrmSpeaker(speaker, _speakerAnimatorController);

                    // ヒエラルキー上の位置を調整
                    speakerViewObject.transform.SetParent(_currentRoomSpaceView.Root.transform);

                    // SubtitleViewにSpeakerをセット
                    _subtitleView.SetUp(speaker, _configurationRepository.IsSubtitleEnabled);
                })
                .RegisterTo(_cts.Token);
        }

        /// <summary>
        /// RoomSpaceが生成されたときの処理
        /// </summary>
        private async UniTask CreateRoomSpaceViews(RoomSpace roomSpace, CancellationToken ct)
        {
            if (roomSpace == null) return;

            // RoomSpaceViewを生成
            var roomSpaceView = RoomSpaceView.Create();

            // SpeakerCameraViewを生成
            var speakerCameraView = Object.Instantiate(_speakerCameraViewPrefab);
            speakerCameraView.Initialize(roomSpace.SpeakerCamera);

            // UguiRoomSpaceBackgroundViewを生成
            // 背景の実装はいくつか考えられるが、今回は「uGUI実装版」を使うことにする
            var backgroundView = Object.Instantiate(_uguiRoomSpaceBackgroundViewPrefab);
            backgroundView.Initalize(roomSpace, speakerCameraView.Camera);

            // RoomSpaceViewに登録
            roomSpaceView.Initalize(roomSpace, backgroundView, speakerCameraView);

            // 現在のRoomSpaceViewを登録
            _currentRoomSpaceView = roomSpaceView;

            // RoomSpaceがDisposeされたらRoomSpaceViewを破棄
            await roomSpace.OnDisposeAsync.AttachExternalCancellation(ct);

            // 現在のRoomSpaceViewの参照を破棄
            _currentRoomSpaceView = null;

            // RoomSpaceViewを破棄
            // 同時にRoomSpaceViewに紐づいているSpeakerCameraViewなどもまとめて破棄される
            Object.Destroy(roomSpaceView.Root);
        }
    }
}

今回の実装ではRoomSpaceViewBinderに責務が集中してしまっています。
まだなんとかなる規模ではありますが、これ以上ややこしくなりそうならクラスの分割を検討したいです。

Visitorパターンの試用

過去に書いた記事でも紹介していますが、型による分岐を消すテクニックとしてVisitorパターンが存在します。

今回はこれをISpeakerSourceで採用してみました。
ISpeakerSourceは「Speaker(事実上のVRM)を読み込むために必要な情報」を抽象化したものです。
今回は実装としてはLocalSpeakerSourceしか存在しませんが、たとえば将来的にVRoidHubSpeakerSourceみたいなのが増える可能性があります。

using System.Threading;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Core.Interfaces
{
    /// <summary>
    /// Speakerのロード元を表す
    /// </summary>
    public interface ISpeakerSource
    {
        UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct);
    }

    /// <summary>
    /// VisitorパターンでISpeakerSourceを処理するためのインタフェース
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public interface ISpeakerSourceVisitor<T>
    {
        UniTask<T> Visit(LocalSpeakerSource source, CancellationToken ct);
    }

    /// <summary>
    /// ローカルファイルからSpeakerをロードするための実装
    /// </summary>
    public readonly struct LocalSpeakerSource : ISpeakerSource
    {
        public string Path { get; }

        public LocalSpeakerSource(string path)
        {
            Path = path;
        }

        public UniTask<T> Accept<T>(ISpeakerSourceVisitor<T> visitor, CancellationToken ct)
        {
            return visitor.Visit(this, ct);
        }

        public override string ToString()
        {
            return $"{nameof(Path)}: {Path}";
        }
    }
}
using System;
using System.Threading;
using AvatarSpeaker.Core;
using AvatarSpeaker.Core.Interfaces;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using UniVRM10;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// VOICEVOXを利用したSpeaker実装を提供する
    /// </summary>
    public sealed class VoicevoxSpeakerProvider : ISpeakerProvider, ISpeakerSourceVisitor<Speaker>, IDisposable
    {
        private readonly VoicevoxProvider _voicevoxProvider;

        public VoicevoxSpeakerProvider(VoicevoxProvider voicevoxProvider)
        {
            _voicevoxProvider = voicevoxProvider;
        }

        public void Dispose()
        {
            // do nothing
        }

        public UniTask<Speaker> LoadSpeakerAsync(ISpeakerSource source, CancellationToken ct)
        {
            // VisitorパターンでISpeakerSourceを処理する
            return source.Accept(this, ct);
        }

        /// <summary>
        /// LocalSpeakerSourceに対する実装
        /// </summary>
        public async UniTask<Speaker> Visit(LocalSpeakerSource source, CancellationToken ct)
        {
            // ローカルパスからVRMをロードしてInstantiate
            var vrmInstance = await Vrm10.LoadPathAsync(source.Path, ct: ct);
            
            // LoadPathAsyncでのctの扱い方が不明なので
            // ここでキャンセルされていたら破棄して例外を投げる
            if (ct.IsCancellationRequested)
            {
                Object.Destroy(vrmInstance.gameObject);
                ct.ThrowIfCancellationRequested();
            }
            
            // VoicevoxSpeakerを生成
            var speaker = new VoicevoxSpeaker(vrmInstance, _voicevoxProvider);
            return speaker;
        }
    }
}

Visitorパターンをつかった感想としては「Coreで抽象化したのに、その具象を同じCoreに置く必要があり、Infrastructuresを用意した意味が薄れてしまうから失敗だった 」です。

Coreで抽象化してInfrastructuresで実装する」という思想なのに、実際はLocalSpeakerSourceCoreに配置する必要が出てきてしまいました。

というのもVisitorパターンはダブルディスパッチ、つまり「オブジェクト同士を相互参照させる」ことで堅牢さを作り出すテクニックです。そのため「どんな具象が存在するのかを抽象化した場所で知っていないといけない」ということになり、そもそも今回のCoreInfrastructuresに分ける思想と噛み合ってませんでした。

今回Visitorパターンを使った理由は「ISpeakerSourceへの操作を型安全に行いたい」からです。これが実現できるのであれば別にVisitorパターンである必要はありません。なのでCode Analyzerなどを使って型安全性が保てるのならそっちでもいいかなとは思います。

VoicevoxSpeaker実装の解説

VoicevoxSpeakerVOICEVOXを使って発話するSpeaker実装です。継承元としてCore.UnityAdapter.VrmSpeakerを挟んでいます。

using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using R3;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly Subject<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _speechRegisterSubject =
                new();

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストだけ投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            _speechRegisterSubject
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .RegisterTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override ReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Observableを非同期処理を行えるQueueとして利用
            _speechRegisterSubject.OnNext((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _speechRegisterSubject.Dispose(true);
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}

発話のためにVoicevoxClientSharpを使っています(もともとこのライブラリを使った実装サンプルという体でしたし)。

こだわっている点をあげると読み上げのリクエストをキューに追加しつつ非同期で処理している点です。
VoicevoxClientSharpで読み上げを行う流れは次の2ステップとなっています。

  1. VOICEVOXに音声合成を依頼してwavを生成してもらう
  2. Unity上でwavを再生する

この 1. についてはレスポンスに数秒待たされることがあります。そのため「1と2」を1つのタスクとして扱ってしまうと読み上げのテンポが悪くなってしまいます。
そのため今回の実装では「1」のVOICEVOXへのwav合成依頼は先に発行し非同期TaskをQueueに追加し、終わってたら音声合成を行うという流れにしました。

また、非同期Queueの実装としてR3.Obseravbleを使い、それをSubscribeAwaitで処理するようにしています。

似たような「非同期Queue」っぽい概念としてAsyncEnumerableがあり、実際これも選択肢として考えました。
ですが今回のような「外から発話リクエストが送り込まれる」というシチュエーションはPush型として捌いた方が素直です。
なのでPull型のAsyncEnumerableよりもPush型のObservableを選択しました。

AsyncEnumerableで実装してみる場合

R3.Observableを使わない場合は、次の2とおりの実装ができます。

A. UniTaskAsyncEnumerableだけを使って実装する
B. UniRx.ObseravbleUniTaskAsyncEnumerableを併用する


UniTaskAsyncEnumerableだけを使って実装する場合のパターン。
Channelを使えば実現できます。

using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq; // UniTask.Linqへの参照が必要
using R3;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

// UniTaskAsyncEnumerableだけを使って実装するパターン
namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly
            ChannelWriter<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _channelWriter;

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // Channelを作成
            var channel = Channel
                .CreateSingleConsumerUnbounded<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource,
                    CancellationToken)>();

            // ChannelWriterを取得
            _channelWriter = channel.Writer;

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストだけ投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            channel.Reader
                .ReadAllAsync()
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .RegisterTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override ReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Channelに音声合成のタスクを書き込む
            _channelWriter.TryWrite((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _channelWriter.TryComplete();
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}

UniRx.ObseravbleUniTaskAsyncEnumerableを併用するパターン。
Obseravableを作って、それをUniTaskAsyncEnumerableに変換してしまえばOKです。

using System;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Core.Models;
using AvatarSpeaker.Core.UnityAdapter.VRM;
using AvatarSpeaker.Infrastructures.Voicevoxes;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UniRx;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;
using Object = UnityEngine.Object;

// UniRx.ObseravbleとUniTaskAsyncEnumerableを併用するパターン
namespace AvatarSpeaker.Infrastructures.VoicevoxSpeakers
{
    /// <summary>
    /// SpeakerのVOICEVOXとVRM実装
    /// </summary>
    public class VoicevoxSpeaker : VrmSpeaker
    {
        private readonly CancellationTokenSource _cancellationTokenSource = new();
        private readonly ReactiveProperty<string> _currentSpeakingText = new("");

        private readonly UniTaskCompletionSource _onDisposeUniTaskCompletionSource = new();

        private readonly Subject<(ValueTask<SynthesisResult>, AutoResetUniTaskCompletionSource, CancellationToken)>
            _speechRegisterSubject =
                new();

        private readonly VoicevoxProvider _voicevoxProvider;
        private readonly GameObject _vrmGameObject;

        public VoicevoxSpeaker(Vrm10Instance vrm10Instance, VoicevoxProvider provider)
        {
            GameObject = vrm10Instance.gameObject;
            Vrm10Instance = vrm10Instance;

            // SpeakerのIDを設定
            Id = $"voicevox_vrm_{vrm10Instance.gameObject.GetInstanceID().ToString()}";

            _voicevoxProvider = provider;
            _vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceを追加
            var audioSource = _vrmGameObject.AddComponent<AudioSource>();
            // VRMをリップシンクするためのコンポーネントを追加
            var lipSync = _vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();

            // 音声合成の再生を行うコンポーネントを追加
            var voicevoxSpeakPlayer = _vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();

            // 紐づける
            voicevoxSpeakPlayer.AudioSource = audioSource;
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(lipSync);

            // 音声合成の再生依頼が流れてくるので、ここで非同期的に逐次処理する
            // VOICEVOXに事前に音声合成リクエストだけ投げておき、終わったら音声再生を実行する
            // 音声再生が終わったら次の音声合成リクエストが完了するのを待つ、を繰り返す
            _speechRegisterSubject
                // IObservableはSubscribeAwaitがない
                // ただし、UniTaskAsyncEnumerableに変換することでSubscribeAwaitを利用できる
                // 今回のシチュエーションでは変換しても全く問題ない
                .ToUniTaskAsyncEnumerable()
                .SubscribeAwait(async (values, ct) =>
                {
                    if (voicevoxSpeakPlayer == null) return;

                    var (task, autoResetUniTaskCompletionSource, ctsToken) = values;
                    try
                    {
                        // 音声合成のタスクが完了するまで待機
                        var result = await task;
                        ct.ThrowIfCancellationRequested();

                        // 現在の発話中のテキストを更新
                        _currentSpeakingText.Value = result.Text;

                        await voicevoxSpeakPlayer.PlayAsync(result, ctsToken);
                        autoResetUniTaskCompletionSource.TrySetResult();
                    }
                    catch (OperationCanceledException)
                    {
                        autoResetUniTaskCompletionSource.TrySetCanceled();
                    }
                    catch (Exception e)
                    {
                        autoResetUniTaskCompletionSource.TrySetException(e);
                    }
                    finally
                    {
                        // 発話中のテキストをクリア
                        _currentSpeakingText.Value = "";
                    }
                })
                .AddTo(_cancellationTokenSource.Token);
        }

        // GameObjectのIDをSpeakerのIDとして利用
        public sealed override string Id { get; }

        public override GameObject GameObject { get; }
        public override Vrm10Instance Vrm10Instance { get; }

        public override IReadOnlyReactiveProperty<string> CurrentSpeakingText => _currentSpeakingText;

        /// <summary>
        /// 目の位置をSpeakerの顔の位置として利用する
        /// </summary>
        public override Vector3 FacePosition => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.position;

        /// <summary>
        /// 腰の位置をSpeakerの前方向として利用する
        /// </summary>
        public override Vector3 BodyForward => Vrm10Instance.Runtime.LookAt.LookAtOriginTransform.forward;

        public override UniTask OnDisposeAsync => _onDisposeUniTaskCompletionSource.Task;


        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            var speakParameter = CurrentSpeakParameter.CurrentValue;
            await SpeakAsync(text, speakParameter, ct);
        }

        /// <summary>
        /// 発話する
        /// </summary>
        public override async UniTask SpeakAsync(string text, SpeakParameter speakParameter, CancellationToken ct)
        {
            using var lcts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cancellationTokenSource.Token);
            var autoResetUniTaskCompletionSource = AutoResetUniTaskCompletionSource.Create();

            var synthesiser = _voicevoxProvider.Synthesizer.CurrentValue;

            // Voicevoxの音声合成を開始
            var task = synthesiser.SynthesizeSpeechAsync(
                text: text,
                styleId: speakParameter.Style.Id,
                speedScale: (decimal)speakParameter.SpeedScale,
                pitchScale: (decimal)speakParameter.PitchScale,
                volumeScale: (decimal)speakParameter.VolumeScale,
                cancellationToken: lcts.Token);

            // Observableを非同期処理を行えるQueueとして利用
            _speechRegisterSubject.OnNext((task, autoResetUniTaskCompletionSource, lcts.Token));

            // 読み上げが完了するまで待機
            await autoResetUniTaskCompletionSource.Task;
        }

        protected override void OnDisposed()
        {
            _cancellationTokenSource.Cancel();
            _cancellationTokenSource.Dispose();
            _speechRegisterSubject.Dispose();
            _currentSpeakingText.Dispose();

            Object.Destroy(_vrmGameObject);
            _onDisposeUniTaskCompletionSource.TrySetResult();
        }
    }
}

挙動はどっちで書いてもだいたい同じです。使いこなせる自信のあるやり方を採択すればOK。

UIsのPresenter実装解説

UIsパッケージは基本的にMV(R)Pパターンをベースにしています。uGUIやキーボードからの入力操作をUseCasesに渡し、アプリケーションを外から操作することができるようにしています。

例として、シンプルな「カメラをリセットするボタン」まわりの実装を紹介します。

resetcamera.gif

using AvatarSpeaker.UseCases;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using UnityEngine;
using UnityEngine.UI;
using VContainer;

namespace AvatarSpeaker.UIs.Presenters
{
    public class ResetCameraPresenter : MonoBehaviour
    {
        [SerializeField] private Button _resetCameraButton;

        private SpeakerCameraUseCase _speakerCameraUseCase;

        [Inject]
        public void Inject(SpeakerCameraUseCase speakerCameraUseCase)
        {
            _speakerCameraUseCase = speakerCameraUseCase;

            _resetCameraButton
                .OnClickAsAsyncEnumerable(destroyCancellationToken)
                .Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace());
        }
    }
}

やってることは単純で、ButtonのイベントがきたらSpeakerCameraUseCase.FocusOnCurrentSpeakerFace()という処理を呼び出しているだけです。
FocusOnCurrentSpeakerFaceSpeakerの位置を参照してカメラを正面に再配置するメソッドです。
Viewsは基本このような「uGUIのイベントをうけてUseCasesを触る」という処理が記述されています。


そして今回の実装ではUniTaskUniTaskAsyncEnumerableを使ってuGUIのイベントを扱う方向で統一しています。

// UniTaskAsyncEnumerableで書いた場合
_resetCameraButton
    .OnClickAsAsyncEnumerable(destroyCancellationToken)
    .Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace());

// R3.Observableで書いた場合
_resetCameraButton
    .OnClickAsObservable()
    .Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace())
    .AddTo(this);

挙動としてはだいたい同じなのでどっちを使っても問題はないです。

ですが、今回は明確な理由があってUniTaskAsyncEnumerableを使っています。
それは「R3TextMeshProのUIコンポーネントのObservable化をサポートしていなかったから」です。

using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using R3;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace AvatarSpeaker.UIs.Presenters
{
    public class SamplePresenter : MonoBehaviour
    {
        [SerializeField] private InputField _normalInputField;

        [SerializeField] private TMP_InputField _textMeshProInputField;

        private void Start()
        {
            // R3, 通常のInputField
            _normalInputField
                .OnEndEditAsObservable()
                .Subscribe(_ => { })
                .AddTo(this);

            // R3, TextMeshProのInputField
            // 存在しないためエラーになる
            // _textMeshProInputField
            //     .OnEndEditAsObservable()
            //     .Subscribe(_ =>
            //     {
            //
            //     })
            //     .AddTo(this);

            // UniTask 通常のInputField
            _normalInputField
                .OnEndEditAsAsyncEnumerable(destroyCancellationToken)
                .Subscribe(_ => { });
            
            // UniTask TextMeshProのInputField
            // (UniTask.TextMeshProへの参照が必要)
            _textMeshProInputField
                .OnEndEditAsAsyncEnumerable(destroyCancellationToken)
                .Subscribe(_ => { });
        }
    }
}

UniTaskであればTextMeshProコンポーネントのUniTaskAsyncEnumerable化ができるためこちらを使用しました。
ObservableUniTaskAsyncEnumerableが混ざってしまうのも嫌なので、今回はUniTaskAsyncEnumerableで統一することにしています。

HttpServerの解説

REST APIとしてHTTPでSpeakerに発話させたいため、簡易なHTTPサーバーを実装しています。

C#のHttpListenerを使ってサーバー本体の実装を行い、各リクエストに応じたハンドリングを自作したControllerクラスに任せるという仕組みになっています。
ControllerクラスはAttributeでHTTPメソッドとRouteを定義できるようにしており、そこそこの拡張性を持たせています。
(例: [Post("/api/v1/speakers/current/speak")]Attributをメソッド定義に追加するだけでREST APIとして公開ができる)

using System;
using System.Collections.Generic;
using System.Net;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using UnityEngine;

namespace AvatarSpeaker.Http.Server
{
    /// <summary>
    /// HTTPサーバー
    /// </summary>
    public sealed class HttpServer : IDisposable
    {
        private delegate ValueTask Handler(HttpListenerRequest req, HttpListenerResponse res, CancellationToken ct);

        private readonly HttpListener _httpListener = new();

        private readonly Dictionary<(string, string), Handler> _routes = new();
        private CancellationTokenSource _cts = new();
        private bool _isDisposed;

        public void Dispose()
        {
            if (_isDisposed) return;

            Stop();
            ((IDisposable)_httpListener)?.Dispose();
            _isDisposed = true;
        }

        private void AddGet(string localPath, Handler handler) => Method("GET", localPath, handler);
        private void AddPost(string localPath, Handler handler) => Method("POST", localPath, handler);
        private void AddPut(string localPath, Handler handler) => Method("PUT", localPath, handler);
        private void AddDelete(string localPath, Handler handler) => Method("DELETE", localPath, handler);

        private void Method(string httpMethod, string localPath, Handler handler)
        {
            _routes.Add((httpMethod, localPath), handler);
        }


        public void Start(int port)
        {
            if (_isDisposed) throw new ObjectDisposedException(nameof(HttpServer));

            Stop();
            _cts = new CancellationTokenSource();

            _httpListener.Prefixes.Clear();
            _httpListener.Prefixes.Add($"http://127.0.0.1:{port}/");

            _httpListener.Start();
            _ = HandleRequestsAsync(_cts.Token);
        }

        public void Stop()
        {
            if (_isDisposed) return;

            _cts?.Cancel();
            _cts?.Dispose();
            _cts = null;

            _httpListener.Stop();
        }

        private async ValueTask HandleRequestsAsync(CancellationToken ct)
        {
            while (!ct.IsCancellationRequested)
            {
                try
                {
                    var context = await _httpListener.GetContextAsync().ConfigureAwait(false);
                    ct.ThrowIfCancellationRequested();
                    var request = context.Request;
                    var response = context.Response;
                    try
                    {
                        if (_routes.TryGetValue((request.HttpMethod, request.Url.LocalPath), out var handler))
                        {
                            // メインスレッドに切り替えてから処理
                            await UniTask.SwitchToMainThread();
                            await handler(request, response, ct);
                        }
                        else
                        {
                            response.StatusCode = (int)HttpStatusCode.NotFound;
                        }
                    }
                    catch (Exception e)
                    {
                        Debug.LogException(e);
                        response.StatusCode = (int)HttpStatusCode.InternalServerError;
                    }
                    finally
                    {
                        response.Close();
                    }
                }
                catch (ObjectDisposedException)
                {
                    // ignore
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    Debug.LogException(ex);
                }
            }
        }

        /// <summary>
        /// Controllerを登録する
        /// Reflectionを使っているので、パフォーマンスに注意
        /// </summary>
        public void RegisterController(BaseController baseController)
        {
            var type = baseController.GetType();
            var methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Public);

            foreach (var method in methods)
            {
                var h = method.GetCustomAttribute<RouterAttribute>();
                if (h == null) continue;

                var handler = new Handler((req, res, ct) =>
                    (ValueTask)method.Invoke(baseController, new object[] { req, res, ct }));

                switch (h)
                {
                    case Get:
                        AddGet(h.LocalPath, handler);
                        break;
                    case Post:
                        AddPost(h.LocalPath, handler);
                        break;
                    case Put:
                        AddPut(h.LocalPath, handler);
                        break;
                    case Delete:
                        AddDelete(h.LocalPath, handler);
                        break;
                }
            }
        }
    }
}
using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AvatarSpeaker.Http.Models;
using AvatarSpeaker.Http.Server;
using AvatarSpeaker.UseCases;
using Cysharp.Threading.Tasks;

namespace AvatarSpeaker.Http
{
    /// <summary>
    /// /api/v1/speakers/
    /// </summary>
    public sealed class SpeakerController : BaseController
    {
        private readonly SpeakerUseCase _speakerUseCase;

        public SpeakerController(SpeakerUseCase speakerUseCase)
        {
            _speakerUseCase = speakerUseCase;
        }

        /// <summary>
        /// 現在のSpeakerの設定で発話する
        /// </summary>
        [Post("/api/v1/speakers/current/speak_current_parameters")]
        public async ValueTask SpeakCurrentParamsAsync(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var data = await ReadAsJson<SpeakRequestCurrentParamsDto>(request, response);

                if (data.Text == null)
                {
                    response.StatusCode = (int)HttpStatusCode.BadRequest;
                    return;
                }

                // HTTPレスポンスはすぐに返したいのでForget
                _speakerUseCase.SpeakByCurrentSpeakerAsync(data.Text, default).Forget();

                response.StatusCode = (int)HttpStatusCode.OK;
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }


        /// <summary>
        /// 発話する
        /// </summary>
        [Post("/api/v1/speakers/current/speak")]
        public async ValueTask SpeakAsync(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var data = await ReadAsJson<SpeakRequestDto>(request, response);

                if (data.Text == null)
                {
                    response.StatusCode = (int)HttpStatusCode.BadRequest;
                    return;
                }

                // SpeakerUseCaseを用いて発話命令を送る
                // HTTPレスポンスはすぐに返したいのでForget
                _speakerUseCase
                    .SpeakByCurrentSpeakerAsync(data.Text, data.Parameters.ToCore(), default)
                    .Forget();

                response.StatusCode = (int)HttpStatusCode.OK;
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }

        /// <summary>
        /// 現在の設定値を取得する
        /// </summary>
        [Get("/api/v1/speakers/current/parameters")]
        public async ValueTask GetParameters(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var speaker = _speakerUseCase.GetCurrentSpeaker();
                if (speaker == null)
                {
                    response.StatusCode = (int)HttpStatusCode.NotFound;
                    return;
                }

                var parameters = speaker.CurrentSpeakParameter.CurrentValue;
                var dto = new SpeakParametersDto
                {
                    Style = parameters.Style.Id,
                    SpeedScale = parameters.SpeedScale,
                    PitchScale = parameters.PitchScale,
                    VolumeScale = parameters.VolumeScale
                };

                await SuccessAsJson(response, dto);
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }

        /// <summary>
        /// 現在の設定値を上書きする
        /// </summary>
        [Put("/api/v1/speakers/current/parameters")]
        public async ValueTask PutParameters(
            HttpListenerRequest request,
            HttpListenerResponse response,
            CancellationToken ct)
        {
            try
            {
                var speaker = _speakerUseCase.GetCurrentSpeaker();
                if (speaker == null)
                {
                    response.StatusCode = (int)HttpStatusCode.NotFound;
                    return;
                }

                var dto = await ReadAsJson<SpeakParametersDto>(request, response);
                var parameters = dto.ToCore();

                if (!parameters.Validate())
                {
                    response.StatusCode = (int)HttpStatusCode.BadRequest;
                    return;
                }

                speaker.ChangeSpeakParameter(parameters);

                response.StatusCode = (int)HttpStatusCode.OK;
            }
            catch (Exception)
            {
                response.StatusCode = (int)HttpStatusCode.BadRequest;
            }
        }
    }
}
using System;
using System.Collections.Generic;
using System.Threading;
using AvatarSpeaker.Core.Configurations;
using R3;

namespace AvatarSpeaker.Http.Server
{
    /// <summary>
    /// HTTPServerの実行を管理する
    /// </summary>
    public sealed class HttpServerRunner : IDisposable
    {
        private readonly IConfigurationRepository _configurationRepository;

        private readonly IEnumerable<BaseController> _controllers;
        private readonly CancellationTokenSource _cts = new();
        private readonly HttpServer _currentHttpServer;
        
        public HttpServerRunner(IEnumerable<BaseController> controllers,
            IConfigurationRepository configurationRepository)
        {
            _currentHttpServer = new HttpServer();
            _controllers = controllers;
            _configurationRepository = configurationRepository;
        }


        public void Dispose()
        {
            _cts.Cancel();
            _cts.Dispose();
            _currentHttpServer?.Dispose();
        }

        public void Start()
        {
            foreach (var controller in _controllers)
            {
                // Controllerを登録する
                _currentHttpServer.RegisterController(controller);
            }

            _configurationRepository
                .HttpServerSettings
                .Subscribe(x =>
                {
                    if (x.IsEnabled)
                        _currentHttpServer.Start(x.Port);
                    else
                        _currentHttpServer.Stop();
                })
                .RegisterTo(_cts.Token);
        }
    }
}

テストについて

今回テストは書けていません。単純にアドベントカレンダーに間に合わせるために急いで実装していたのもあり、テストについて気が回っていませんでした。ここは明確な反省点です。

ただHumbleObjectパターンの思想に則ると、「UseCaseのテスト」は比較的書きやすくなっていると考えます。
InfraViewsUIsのテストは実装が難しい割に旨味があまりないのでコスパが悪いとは思います。

DIについて

今回はVContainerを使ってDIを実現しています。

それ以上は特に説明することはないです。

その他

その他、ぼんやりと考えていた設計思想を書きます。

  • RoomSpaceは再生成可能にしたかった
    • 動的に破棄・生成をしてもアプリケーションが動作するように
    • 「現在の状態を保存する/読み込む」みたいな機能を想定するとほしい
  • Speakerは複数体配置できるようにしたかった
    • 実装上は現状1体しかRoomSpaceに配置しない形式にはしたが、拡張自体は容易なはず
  • ViewsUIsが多少汚いのは許容する
    • Coreさえキレイならその汚れは下位レイヤーが引き受けてよい、という考え
    • DDDには腐敗防止層という概念があったりするし、「本質的に面倒くさい処理はどうやっても泥臭くなる」という割り切りが自分の中にある

今回の設計で実装を進めた感想

微妙だと思ったところ

  • 動作確認が可能になるまでが長い

    • CoreInfraViewsUIsをすべて定義・実装しないと動作確認までもっていけない
    • 「雑に実装してとりあえず動きを見る」という方向性と相性が悪い
  • 機能を増やすときにいじるべき場所が多い

    • 機能を1つ実装するにしても複数パッケージをまたぐ必要があり手間が多い
  • Coreをキレイに定義するのが非常に難しい

    • 中核となるCoreをどれだけキレイに定義して運用するかにすべてがかかっている
    • ここが上手くできるのであればこの設計は凄く上手にワークする
      • 逆にここが適当だと効果は薄い
  • 具象に引っ張られてすぎて、抽象化した意味が薄い部分が出ている

    • 今回は「VOICEVOX」と「VRM」を使うことを目的にしつつ、そこを抽象化して扱っているため手段と目的が一致しないことになっていた
      • 「抽象的な設計で実装する」は手段であり、その目的は「将来的な仕様変更や機能拡張に柔軟に対応できるようにするため」である。
      • 一方で「VOICEVOXを使う」「VRMを使う」というほぼ実装決め打ちのサンプルプログラムなので、微妙に噛み合ってない
      • 結果として「抽象化して扱っている割に実装がVRM/VOICEVOXしかないので、回りくどいことだけやって終わった」という感じになっている
  • UIsやViewsやInfraに”汚れ”が溜まりやすい

    • CoreUseCasesをキレイにしようとした場合、その辻褄合わせが下位レイヤーに寄りがちになってしまう
      • RoomSpaceViewBinderが結構キタナイ
      • 逆に「CoreUseCasesがキレイならそれでいいじゃん」ともいえる
        • 少なくともこの2つのパッケージさえキレイであればアプリケーションの根幹には影響を与えることはないため「下位レイヤーを捨てて再実装する」という選択肢が取れる
  • ドメインモデル貧血症になりつつある

    • Coreがほとんどロジックをもたず、UseCasesInfraに知識が漏れ出している感が否めない
    • 根幹の「テキストの読み上げ処理」がInfraにまるっと実装されておりCore.Speakerがスカスカになっている
      • 非同期Queueを使った読み上げタイミングの管理はCoreに実装するべきだったように思える
        • 抽象化を工夫すればCoreに持ってこれる気がする、あとで直すかも

良かったところ

  • 要所要所で考えるべきポイントが明確化される

    • パッケージの責務が明確なので、どの機能をどこに作るべきかがハッキリしており迷うことが少ない
      • 逆に迷った場合は「このパッケージの本来の意図はなんだったっけ?」に立ち返ると判断しやすくなる
        • たとえば、UIsパッケージ外でUIに関心を持ったコードが登場したらそれは「間違い」と断言できる
    • 「要件を抽象化してどうCoreで扱うか」の部分だけは非常に難しくここだけスキルが要求されるが、それさえできるのであればかなり扱いやすい
      • いじるべきパッケージが多いというデメリットもあるが、逆にいうと「実装はただの作業であって考えることがあんまりない」というメリットがあるともいえる
  • 後半になるほど実装速度が加速する

    • 序盤の動作確認が可能な状況にするまでが長いが、いったん動作確認ができるようなればそれ以降の機能追加は非常に早い
  • 機能拡張や保守が容易

    • Coreで抽象化しているため、実装ライブラリを差し替えたり追加が比較的容易にできる(はず)
    • パッケージごとの役割を把握すればコードが読めるので、チーム開発で効果を発揮する

全体を通しての感想

今回は「VoicevoxClientSharpというライブラリを作ったからこれのサンプル実装を作って公開しよう」という目的から始まり、実装していくうちに「これガチガチに設計したら面白いんじゃない?」と目的がズレていきました。それもあってか抽象化が上手くできてない部分があるような気もするのですが、全体としてはまぁまぁよく設計できたと思ってます。

この設計で実装を始めた当初は「失敗した!この規模のプロダクトにこれはやりすぎだ!手間がかかりすぎる!」と後悔していました。まずどんなパッケージ構造にするかを決め、Coreにいろいろインタフェースを用意して、ViewsInfraでどうGameObjectを扱うかを考え、UIsを実装し、それらが全部終わってやっと動作確認ができるという状況でした。そのため実装開始から動作確認ができるようになるまでに数日かかってしまいました。

とまぁ、実装当初は後悔していましたが、後半になるにつれて「むしろこの設計やっぱり正解では?」と考えが変わりました。
骨組みができるまでは大変ですが一度そこができてしまえばあとは流れ作業で機能追加ができました。後から「字幕機能を追加しちゃおう」とか考えられる時間的な余裕と、それを容易に受け入れられる設計の余地を作ることもできました。余裕があったならテストも書け。

ということで、最終的な感想としては「本当に使い捨て前提のプロトタイピング以外では、基本的にクリーンアーキテクチャを意識したほうがよい」です。
(実装スピードが最優先のプロトタイピングで、ガチガチのアーキテクチャを組むのはやり過ぎ感が否めません)。

ただ、ゲーム開発ではクリーンアーキテクチャが上手く活用できない領域があることも理解しています。とくにアクション系のゲームのインゲームをクリーンアーキテクチャで構築するのはコスパが非常に悪いでしょう。その場合は以前の記事で紹介したように、インゲーム部分はUnityベタで書いて、アウトゲーム部分だけでもクリーンさを意識する、みたいなハイブリットさを検討してもよいと思います。

まとめ

  • クリーンアーキテクチャを意識して非ゲームアプリケーションを作ってみた
  • 実装は出だしのスピード感は遅いが、後半になるにつれて実装速度は加速する
  • キレイな抽象化は非常に難しいが、それさえ達成できれば全体としてはとてもクリーンになる(その抽象化がそうそうできない、という話ではあるが…)

おまけ

私が会社で開発しているプロダクトも似たようなアーキテクチャで組んでいます。こちらも興味があれば御覧ください。

今回の設計はそのアーキテクチャの簡易版みたいなものでした。

25
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?