はじめに
普段はクラス設計の話をよくしているのですが、今回はより大局的な「アーキテクチャ設計」の話です。
この記事では「非ゲームアプリケーションを、クリーンアーキテクチャを意識してUnityで作るとどんなアーキテクチャとなるのか」を紹介します。
前回「VoicevoxClientSharp: C#やUnityからVOICEVOXで音声合成するライブラリの紹介」という記事を投稿しました。
「VoicevoxClientSharp」というライブラリをせっかく作ったのだから、それを使ったサンプルアプリケーションを作ってみようと考えました。そしてサンプルアプリを実際に作っている途中で「これVoicevoxClientSharp
の使用例で終わらせずに、とことん設計をこだわってみてそれをネタにしたほうが面白いじゃん」と思い、この記事を書くに至りました。
もともとサンプル実装用途だった小さいプロダクトを全力で真面目に設計するとどうなるのか? という内容です。
作ったサンプルアプリケーションについて
- VRMアバターを読み込み、VOICEVOXで発話しながらアバターをリップシンクさせるアプリです
- REST APIでテキストデータを送り込むことができます。コメントビューアなどと連携して生放送コメントの読み上げなどに使う想定
概要
リポジトリ
MITライセンスです。適当にforkして機能拡張したり、それを公開してもらって構いません。
依存ライブラリ
- VoicevoxClientSharp
- UniVRM
- R3
- UniTask
- NuGetForUnity
- VContainer
- UnitySimpleFileBrowser
機能要件
今回、VoicevoxClientSharp
のサンプル実装が一応の話の起点だったため、次のようなアプリケーションを想定して作りました。
- Unityで作成するデスクトップ向けアプリケーション
設計思想
規模としてはかなり小さいアプリケーションです。そのため抽象化を挟まずにMonoBehaviour
ベタ書きで書いてもなんとかなるレベルではあります。
しかしそれだけでは味気ないので次のよう条件を付けて設計・実装することにしました。
-
将来的に機能拡張があると想定し備える
- 例
- ローカルからVRMを読み込むだけでなく、サーバーから読み込む可能性がある
- VOICEVOX以外の音声合成ソフトウェアに対応する可能性がある
- 画面の切り替え・複数アバターの同時展開を行う可能性がある
- 背景画像を自由に設定できるようにする可能性がある
- 設定したパラメータを保存できるようにする可能性がある
- などなど…
- 例
-
クリーンアーキテクチャの思想に則る
- パッケージ間の関係性・レイヤ構造・抽象度・安定度を意識する
-
コア部分はUnityにできるだけ非依存とする
- GameObjectやMonoBehaviourを意識した実装はコア部分では禁止する
- ただし、UnityEngineそのものへの依存は禁止しない(理由は後述)
- GameObjectやMonoBehaviourを意識した実装はコア部分では禁止する
以前Unityにおける設計については過去に語ったことがあるのですが、この定義でいうところの「レベル5」を今回は目指しています。
余談:クリーンアーキテクチャ
話が脱線するので折りたたみ。
端的にいえば、クリーンアーキテクチャとは「クリーンなアーキテクチャを作るための思想」のことを指しています。
具体的なアーキテクチャの構成図があるのではなく、「こういうときはこういう考え方で作ると上手くいくことが多いよ」というテクニックを語っているのがクリーンアーキテクチャです。
なので「クリーンアーキテクチャ指向」って言い方をしたほうがしっくりきますね。
クリーンアーキテクチャについて学習するのであれば、まずは書籍を読むことを推奨します。
またAtsushi Nakamuraさんがまとめてくださった記事もわかりやすくてお勧めです。
ちなみに私なりにクリーンアーキテクチャをまとめると次のようになります。
-
うまくいっているアーキテクチャは本質的に同じテクニックを使っている
- 安定度・抽象度が高いものを中心に依存を整理せよ
- データフローと依存関係は分離して考えるべきである
-
クリーンアーキテクチャは「採用する」ものではなく「作っていったら自然とそうなる」ものである
- 設計・アーキテクチャにおける原則や思想を積み重ねていった結果、帰着するのがクリーンアーキテクチャである
- 有名な設計に「SOLID原則」があるが、それもクリーンアーキテクチャの土台の1つである
- 設計・アーキテクチャにおける原則や思想を積み重ねていった結果、帰着するのがクリーンアーキテクチャである
-
パッケージ・レイヤー構成はプロダクト都合にあわせて自由に設計してよい
- 決まり切ったパッケージ・レイヤー構造は存在しない
- 本に書いてある同心円状のレイヤー構造をまるっと再現しようとして失敗している人が多いが、本質はそこではない
- レイヤー構造やパッケージは自由に決めてよい
- スモールスタートし、プロダクトの成長に合わせて随時アーキテクチャを組み直しても全然よい
- 最初から完璧なアーキテクチャを作るのではなく「アーキテクチャを育てていく」のも大事
- 決まり切ったパッケージ・レイヤー構造は存在しない
自分はクリーンアーキテクチャを考えながらも、多少それを崩して使うことが多いです。
開発において「キレイな設計やアーキテクチャを維持すること」は目的ではありません。それは「長期的に安定した運用を行う状況を作る」ための手段です。
アーキテクチャのキレイさにこだわって逆に保守しにくい、チームメンバーがついてこれない、運用コストが大きい状態になったら本末転倒です。
クリーンアーキテクチャを神格化するのもよくないので、ほどよく距離を取って使うくらいがちょうどよいと思います。
設計
ではここからが本題。どのようなことを考え、どのような設計をしたのか。
上手くいった部分、上手くいかなかった部分などをまとめていきます。
全体のパッケージ構成
全体のパッケージ構成は次のようになっています。
パッケージはそれぞれが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サーバー実装などが置かれる場所。 |
また図には登場してませんが、StartUp
とDI
という特殊なパッケージも存在します。
パッケージ名 | 責務 |
---|---|
StartUp | アプリケーションの動作の起点となる操作を行う場所。シーン起動時に一瞬だけ稼働してそれ以降は何もしない。 |
DI | DIコンテナへのバインド設定を定義する場所。今回はVContainerを使っているため、LifetimeScope の定義場所となっている。 |
モデル定義
アプリケーションを成立させるための登場人物(オブジェクト)を定義する必要があります。今回は次のような定義としました。
モデル名 | 責務 |
---|---|
RoomSpace |
「空間」を表す概念。Speaker やRoomCamera はこの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非依存にするとVector3
やQuaternion
といった構造体すらCore
で扱えなくなってしまいます。これは相当に不便です。
そこで今回は「CoreはUnityEngineを部分的に使ってよい」というルールにしました。「UnityEngineへの依存を完全に禁止するとあんまり旨味がない上に手間だけが増える。あとアドベントカレンダーの執筆が間に合わなくなるから。」というのが理由で妥協しました。
-
ルール
-
Core
が扱っていいUnityEngineのオブジェクトはVector3
やQuaternion
といった「データ構造」程度に留める -
GameObject
やMonoBehaviour
への依存は禁止する(UnityのライフサイクルがCore
に入りこまないように)
-
Core.UnityAdapter
Core.UnityAdapter
は「Core
を装飾し、Unityの概念をCore
モジュールに追加する」という場所です。
「Core
は部分的にUnityEngineを触ることができるが、GameObject
やMonoBehaviour
はアクセスしない」というルールを課しました。これはCore
の要素にUnityのライフサイクルが入りこまないように防御する意味で重要な判断でした。
ですが実装を進めるうちに、Core
がGameObject
を知らないとどうしようもないという状況に遭遇しました。
そこで苦肉の策として生まれたのがこのCore.UnityAdapter
です。
「Core
がGameObject
を知らないとどうしようもない」とはどのような状況なのか。
それはInfrastructures
とViews
の存在が関係してきます。
今回、VoicevoxClientSharp
を使う都合上、Infrastructures
でVRMを展開しInstantiate
する必要があります。しかし見た目を管理するViews
でも、この生成したVRMを参照して管理する必要があります。となると、どうにかして同じGameObjectをInfrastructures
からViews
に教える必要があります。この両者のつなぐものはCore.Speaker
しかありません。しかしCore
ではGameObject
を扱うことは禁止です。 詰みです。 どうにかするしかありません。
いろいろと案を考えては見ました。たとえば次のように「外側に別のパッケージを切ってそこを経由してがんばる」といった方法。
これはボツです。「Core.ObjectId
」という存在が謎すぎます。そもそもCore
に置かれるものは「アプリケーションの成立において必須である要素」である必要があります。ですがSpeaker
がSpeaker
として振る舞うためにこのObjectId
は必須ではありません。実装都合でSpeaker
という存在が歪められたように見えてしまい、違和感が強いため却下しました。
そして次に考えたのが、今回採用したUnityAdapter
を挟むというアイデアです。
やってることはシンプルで「Core
がGameObject
を参照できないなら、GameObject
を参照したい部分だけ切り出せばいいじゃん」というものです。
UnityEngineというフレームワークと事実上結婚はしているもののべったりはしたくない、なので別居婚にすればいいじゃん、という発想です。Core.UnityAdapter
自体は非常に薄いパッケージでありロジックなどは一切持っておらず、「Core
を装飾する」以上のことをさせていません。
この方法は(少なくとも今回は)上手くいきました。Core
を汚さずに、事実上GameObject
をCore
に持たせたように見える、という点においては成功しています。
それってクリーンアーキテクチャとしてどうなの?と思うかもしれませんが、自分はこれでもよいと考えています。そもそも唯一無二の正解となるアーキテクチャは存在しません。「開発者同士がアーキテクチャの意図を理解し、破綻なく長期運用ができる」のであれば何でもいいと思ってます。
「今回はこれで破綻なく上手く回っている。だから問題ない。」と言い切ってしまいます。
Infrastructures
Infrastructures
はCore
の実装を行うパッケージです。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
Views
はCore
のモデルをUnityEngine
を使って表現する場所です。Speaker
やSpeakerCamera
をGameObject
やAnimator
、Camera
などに紐づけて扱います。
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
コンポーネントを含んでいます。
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
はその下に属するようにしています。
(SubtitleView
はRoomSpace
とは独立しているので外に出ている)
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
DI
はVContainer
のLifetimeScope
が定義される場所です。
StartUp
StartUp
はアプリケーションの起動処理を実行する場所です。
VContainer
にRegisterEntryPoint
として登録されており、アプリ起動時に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に追加する
データフロー
人間が操作して実際にアプリケーションへ反映されるまでのデータフローですが、次のようになっています。
すべての操作は最終的にCore
に反映され、そのCore
の変動をViews
が監視し反映するというフローになっています。UIs
から直接Views
を操作することはありません。必ずCore
を介し、Core
の状態変更という形でそれがViews
に反映されるフローとしています。
これはまさにThe Clean Architectureで語られていたデータフローと同じですね。
各種解説
全体の構成概要は解説したので、続いて細かい部分や要所ごとの説明をします。
CoreとViewsの紐づけ:Binder
Views
はCore
で定義したモデルをUnityの上での「実体」として表現することを責務としています。
基本的にはCore
のモデル定義に対応する形でそのViews
定義が置かれています。
-
RoomSpace
<->RoomSpaceView
-
Speaker
<->VrmSpeakerView
-
SpeakerCamera
<->SpeakerCameraView
Views
はPrefab化されており、Core
の要素の変動に合わせてInstantiate
/Destroy
されます。
この「Core
の要素に応じてViews
のInstantiate
/Destroy
を行い、Core
の要素とViews
を連携させる」ことを責務とした存在がBinder
です。
Binder
はCore
を監視し、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
で実装する」という思想なのに、実際はLocalSpeakerSource
をCore
に配置する必要が出てきてしまいました。
というのもVisitorパターンはダブルディスパッチ、つまり「オブジェクト同士を相互参照させる」ことで堅牢さを作り出すテクニックです。そのため「どんな具象が存在するのかを抽象化した場所で知っていないといけない」ということになり、そもそも今回のCore
とInfrastructures
に分ける思想と噛み合ってませんでした。
今回Visitorパターンを使った理由は「ISpeakerSource
への操作を型安全に行いたい」からです。これが実現できるのであれば別にVisitorパターンである必要はありません。なのでCode Analyzer
などを使って型安全性が保てるのならそっちでもいいかなとは思います。
VoicevoxSpeaker実装の解説
VoicevoxSpeaker
はVOICEVOX
を使って発話する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ステップとなっています。
- VOICEVOXに音声合成を依頼してwavを生成してもらう
- 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.Obseravble
とUniTaskAsyncEnumerable
を併用する
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.Obseravble
とUniTaskAsyncEnumerable
を併用するパターン。
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
に渡し、アプリケーションを外から操作することができるようにしています。
例として、シンプルな「カメラをリセットするボタン」まわりの実装を紹介します。
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()
という処理を呼び出しているだけです。
FocusOnCurrentSpeakerFace
はSpeaker
の位置を参照してカメラを正面に再配置するメソッドです。
Views
は基本このような「uGUI
のイベントをうけてUseCases
を触る」という処理が記述されています。
そして今回の実装ではUniTask
のUniTaskAsyncEnumerable
を使ってuGUI
のイベントを扱う方向で統一しています。
// UniTaskAsyncEnumerableで書いた場合
_resetCameraButton
.OnClickAsAsyncEnumerable(destroyCancellationToken)
.Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace());
// R3.Observableで書いた場合
_resetCameraButton
.OnClickAsObservable()
.Subscribe(_ => _speakerCameraUseCase.FocusOnCurrentSpeakerFace())
.AddTo(this);
挙動としてはだいたい同じなのでどっちを使っても問題はないです。
ですが、今回は明確な理由があってUniTaskAsyncEnumerable
を使っています。
それは「R3
がTextMeshPro
の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
化ができるためこちらを使用しました。
Observable
とUniTaskAsyncEnumerable
が混ざってしまうのも嫌なので、今回は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
のテスト」は比較的書きやすくなっていると考えます。
Infra
やViews
やUIs
のテストは実装が難しい割に旨味があまりないのでコスパが悪いとは思います。
DIについて
今回はVContainerを使ってDIを実現しています。
それ以上は特に説明することはないです。
その他
その他、ぼんやりと考えていた設計思想を書きます。
-
RoomSpace
は再生成可能にしたかった- 動的に破棄・生成をしてもアプリケーションが動作するように
- 「現在の状態を保存する/読み込む」みたいな機能を想定するとほしい
-
Speaker
は複数体配置できるようにしたかった- 実装上は現状1体しか
RoomSpace
に配置しない形式にはしたが、拡張自体は容易なはず
- 実装上は現状1体しか
-
Views
やUIs
が多少汚いのは許容する-
Core
さえキレイならその汚れは下位レイヤーが引き受けてよい、という考え - DDDには腐敗防止層という概念があったりするし、「本質的に面倒くさい処理はどうやっても泥臭くなる」という割り切りが自分の中にある
-
今回の設計で実装を進めた感想
微妙だと思ったところ
-
動作確認が可能になるまでが長い
-
Core
、Infra
、Views
、UIs
をすべて定義・実装しないと動作確認までもっていけない - 「雑に実装してとりあえず動きを見る」という方向性と相性が悪い
-
-
機能を増やすときにいじるべき場所が多い
- 機能を1つ実装するにしても複数パッケージをまたぐ必要があり手間が多い
-
Coreをキレイに定義するのが非常に難しい
- 中核となる
Core
をどれだけキレイに定義して運用するかにすべてがかかっている - ここが上手くできるのであればこの設計は凄く上手にワークする
- 逆にここが適当だと効果は薄い
- 中核となる
-
具象に引っ張られてすぎて、抽象化した意味が薄い部分が出ている
- 今回は「VOICEVOX」と「VRM」を使うことを目的にしつつ、そこを抽象化して扱っているため手段と目的が一致しないことになっていた
- 「抽象的な設計で実装する」は手段であり、その目的は「将来的な仕様変更や機能拡張に柔軟に対応できるようにするため」である。
- 一方で「VOICEVOXを使う」「VRMを使う」というほぼ実装決め打ちのサンプルプログラムなので、微妙に噛み合ってない
- 結果として「抽象化して扱っている割に実装がVRM/VOICEVOXしかないので、回りくどいことだけやって終わった」という感じになっている
- 今回は「VOICEVOX」と「VRM」を使うことを目的にしつつ、そこを抽象化して扱っているため手段と目的が一致しないことになっていた
-
UIsやViewsやInfraに”汚れ”が溜まりやすい
-
Core
とUseCases
をキレイにしようとした場合、その辻褄合わせが下位レイヤーに寄りがちになってしまう- RoomSpaceViewBinderが結構キタナイ
- 逆に「
Core
とUseCases
がキレイならそれでいいじゃん」ともいえる- 少なくともこの2つのパッケージさえキレイであればアプリケーションの根幹には影響を与えることはないため「下位レイヤーを捨てて再実装する」という選択肢が取れる
-
-
ドメインモデル貧血症になりつつある
-
Core
がほとんどロジックをもたず、UseCases
やInfra
に知識が漏れ出している感が否めない - 根幹の「テキストの読み上げ処理」が
Infra
にまるっと実装されておりCore.Speaker
がスカスカになっている- 非同期Queueを使った読み上げタイミングの管理は
Core
に実装するべきだったように思える- 抽象化を工夫すれば
Core
に持ってこれる気がする、あとで直すかも
- 抽象化を工夫すれば
- 非同期Queueを使った読み上げタイミングの管理は
-
良かったところ
-
要所要所で考えるべきポイントが明確化される
- パッケージの責務が明確なので、どの機能をどこに作るべきかがハッキリしており迷うことが少ない
- 逆に迷った場合は「このパッケージの本来の意図はなんだったっけ?」に立ち返ると判断しやすくなる
- たとえば、
UIs
パッケージ外でUIに関心を持ったコードが登場したらそれは「間違い」と断言できる
- たとえば、
- 逆に迷った場合は「このパッケージの本来の意図はなんだったっけ?」に立ち返ると判断しやすくなる
- 「要件を抽象化してどう
Core
で扱うか」の部分だけは非常に難しくここだけスキルが要求されるが、それさえできるのであればかなり扱いやすい- いじるべきパッケージが多いというデメリットもあるが、逆にいうと「実装はただの作業であって考えることがあんまりない」というメリットがあるともいえる
- パッケージの責務が明確なので、どの機能をどこに作るべきかがハッキリしており迷うことが少ない
-
後半になるほど実装速度が加速する
- 序盤の動作確認が可能な状況にするまでが長いが、いったん動作確認ができるようなればそれ以降の機能追加は非常に早い
-
機能拡張や保守が容易
-
Core
で抽象化しているため、実装ライブラリを差し替えたり追加が比較的容易にできる(はず) - パッケージごとの役割を把握すればコードが読めるので、チーム開発で効果を発揮する
-
全体を通しての感想
今回は「VoicevoxClientSharp
というライブラリを作ったからこれのサンプル実装を作って公開しよう」という目的から始まり、実装していくうちに「これガチガチに設計したら面白いんじゃない?」と目的がズレていきました。それもあってか抽象化が上手くできてない部分があるような気もするのですが、全体としてはまぁまぁよく設計できたと思ってます。
この設計で実装を始めた当初は「失敗した!この規模のプロダクトにこれはやりすぎだ!手間がかかりすぎる!」と後悔していました。まずどんなパッケージ構造にするかを決め、Core
にいろいろインタフェースを用意して、Views
とInfra
でどうGameObject
を扱うかを考え、UIs
を実装し、それらが全部終わってやっと動作確認ができるという状況でした。そのため実装開始から動作確認ができるようになるまでに数日かかってしまいました。
とまぁ、実装当初は後悔していましたが、後半になるにつれて「むしろこの設計やっぱり正解では?」と考えが変わりました。
骨組みができるまでは大変ですが一度そこができてしまえばあとは流れ作業で機能追加ができました。後から「字幕機能を追加しちゃおう」とか考えられる時間的な余裕と、それを容易に受け入れられる設計の余地を作ることもできました。余裕があったならテストも書け。
ということで、最終的な感想としては「本当に使い捨て前提のプロトタイピング以外では、基本的にクリーンアーキテクチャを意識したほうがよい」です。
(実装スピードが最優先のプロトタイピングで、ガチガチのアーキテクチャを組むのはやり過ぎ感が否めません)。
ただ、ゲーム開発ではクリーンアーキテクチャが上手く活用できない領域があることも理解しています。とくにアクション系のゲームのインゲームをクリーンアーキテクチャで構築するのはコスパが非常に悪いでしょう。その場合は以前の記事で紹介したように、インゲーム部分はUnityベタで書いて、アウトゲーム部分だけでもクリーンさを意識する、みたいなハイブリットさを検討してもよいと思います。
まとめ
- クリーンアーキテクチャを意識して非ゲームアプリケーションを作ってみた
- 実装は出だしのスピード感は遅いが、後半になるにつれて実装速度は加速する
- キレイな抽象化は非常に難しいが、それさえ達成できれば全体としてはとてもクリーンになる(その抽象化がそうそうできない、という話ではあるが…)
おまけ
私が会社で開発しているプロダクトも似たようなアーキテクチャで組んでいます。こちらも興味があれば御覧ください。
今回の設計はそのアーキテクチャの簡易版みたいなものでした。