はじめに
随分下火になりましたが、スマホVRは未だ最もお手軽なVRの体験ツールです。
スマホVRの弱点はいくつもありますが、中でも操作手段の少なさはアプリを作る上で大きな課題です。
その課題に対し、最も効果的だと自分が考えている解決方法は音声入力です。
今回は OVRLipSync を使って超お手軽に音声操作を行う方法を提案します。
(正確さが必要な場合は Julius や Google Cloud Speech-to-Text 等を検討しましょう)
(追記:AndroidであればSpeech Recognizer、iOSであればSpeech Frameworkを使用する場合が多いようです)
OVRLipSync を使うと音声を 15 種類の口形素に分解して取得することができます。
OVRLipSync の本来の使い方は、取得した口形素に応じてキャラクターの口の形を変化させてキャラクターが実際に喋っているように見せることです。
https://developer.oculus.com/documentation/audiosdk/latest/concepts/audio-ovrlipsync-sample-details/
今回は OVRLipSync で取得した口形素を利用して、発音された単語が予め用意された選択肢のどれに当たるかを判定します。
環境
OS:Windows 10 Home
ツール:Unity 2019.2.13f
プラグイン:Oculus Lipsync Unity Integration 1.43.0
https://developer.oculus.com/downloads/package/oculus-lipsync-unity/
下準備
- Unity で新規プロジェクト作成。
- Oculus Lipsync Unity Integration をインポート。
- Hierarchy へ
Oculus/LipSync/Prefabs/LipSyncInterface.prefab
を配置。 - Hierarchy へ Audio Souce オブジェクトを配置。
- Audio Souce オブジェクトに
OVR Lip Sync Mic Input
とOVR Lip Sync Context
を Add Component。
後は OVRLipSyncContext コンポーネント(lipSyncContext)から
float[] visemes = lipSyncContext.GetCurrentPhonemeFrame().Visemes;
で口形素を取得できます。
visemes
には口形素の強さ(0~1)が
sil, PP, FF, TH, DD, kk, CH, SS, nn, RR, aa, E, ih, oh, ou
の順番で入っています。
今回は先頭の sil が無音ということがわかっていれば十分です。
データの作成
取得した visemes
はそのままでは手にあまるので、もっとシンプルなデータに変換します。
ここでは、最も強い口形素が変化する度にサンプリングし、無音から無音までを1つのデータとして扱います。
// field
[SerializeField] private OVRLipSyncContext lipSyncContext = null;
private List<int> samples = new List<int>();
// Update is called once per frame
void Update()
{
// 最も強い口形素を求める
var visemes = this.lipSyncContext.GetCurrentPhonemeFrame().Visemes;
var max = 0;
for (var i = 0; i < visemes.Length; i++)
{
if (visemes[max] < visemes[i])
{
max = i;
}
}
var count = this.samples.Count;
if (max != 0) // 0 番目は無音
{
// 変化したらサンプリング
if (count == 0 || this.samples[count - 1] != max)
{
this.samples.Add(max);
}
}
else if (0 < count)
{
// ここまで
DebugLog(this.samples);
// 終わり
this.samples.Clear();
}
}
// コンソール出力してるだけ
static void DebugLog(List<int> list)
{
var message = "";
foreach (var i in list)
{
message += i + ", ";
}
Debug.Log(message);
}
上記を実行して、試しに「りんご」と 10 回発音すると、
12, 1, 13, 4, 8,
12, 1, 8, 13, 4, 8,
12, 5, 14, 8, 1, 13, 4, 8,
1, 14, 13, 5, 8,
12, 8, 13, 4,
12, 8, 14, 13, 4,
12, 8, 13, 4, 8,
12, 8, 14, 13, 5, 4, 8,
12, 8, 13, 4, 8,
12, 8, 1, 10, 13, 5, 4, 8,
となりました。
データの比較
次に別々に発音された単語のデータ同士を比較し、同じ単語かどうかを判定します。
上の「りんご」の例の通り、結構ブレるので次のように評価します。
12, 1, 13, 4, 8,
12, 1, 8, 13, 4, 8,
↓
12, 1, x, 13, 4, 8,
12, 1, 8, 13, 4, 8, → 5 / 6 = 0.83 点
12, 1, 13, 4, 8,
1, 14, 13, 5, 8,
↓
12, 1, x, 13, x, 4, 8,
x, 1, 14, 13, 5, x, 8, → 3 / 7 = 0.43 点
点数が高いほど、近い単語と判定します。
コードにするとこんな感じです。
/// <summary>
/// 2つの int 配列の一致率を取得
/// </summary>
/// <param name="a">配列 A</param>
/// <param name="b">配列 B</param>
/// <returns>一致率(0.0 ~ 1.0)</returns>
public static float GetMatchRate(int[] a, int[] b)
{
var ret = GetMatch(a, 0, b, 0).Rate;
return ret;
}
private static Match GetMatch(int[] data, int dataIndex, int[] other, int otherFrom)
{
var ret = new Match();
var dataRest = data.Length - dataIndex;
var otherRest = other.Length - otherFrom;
// 全部失敗した場合を初期値とする
ret.Miss(dataRest + otherRest);
if (0 < dataRest)
{
// data のひとつと残りの other を比較
for (var i = 0; i < otherRest; i++)
{
var otherIndex = otherFrom + i;
Match match;
if (data[dataIndex] == other[otherIndex])
{
// ここより後の成績を取得
match = GetMatch(data, dataIndex + 1, other, otherIndex + 1);
// ここより前の other を失敗とカウント
match.Miss(i);
// data を(other とまとめて)成功とカウント
match.Hit(1);
}
else
{
// ここより後の成績を取得(other はここも含める)
match = GetMatch(data, dataIndex + 1, other, otherIndex);
// ここより前の other を失敗とカウント
match.Miss(i);
// data を失敗とカウント
match.Miss(1);
}
// 成績がよければ更新
if (ret.Rate < match.Rate)
{
ret = match;
}
}
}
return ret;
}
// 成績を表す構造体
private struct Match
{
// fields
private int hit;
private int count;
// properties
public float Rate { get { return 0 < this.count ? (float)this.hit / this.count : 0f; } }
// methods
public void Hit(int count)
{
this.hit += count;
this.count += count;
}
public void Miss(int count)
{
this.count += count;
}
}
実験
ここまでのコードを使って、発声された単語が「りんご」「みかん」「ぶどう」のどれに当たるか実験してみます。
まず各単語を5回づつ発声して相互比較し、平均点が最も高いものを基準データとして採用しました。
次の通りです。
「りんご」12, 14, 13, 8
「みかん」 8, 12, 5, 10, 8, 5
「ぶどう」 3, 10, 14, 10, 13, 8
次に(りんご)(みかん)(ぶどう)の順に発声し、基準データとの点数を採りました。
(りんご)12, 5, 13, 8
「りんご」0.60 点
「みかん」0.43 点
「ぶどう」0.25 点
(みかん)8, 12, 5, 10, 2, 5
「りんご」0.11 点
「みかん」0.71 点
「ぶどう」0.09 点
(ぶどう)8, 3, 10, 13, 8
「りんご」0.29 点
「みかん」0.38 点
「ぶどう」0.57 点
使えそうですね。
デモ
今回提案した手法をコンポーネント化して組み込んだデモを作成しました。
https://github.com/yananose/VisemeCommandDemo/
コンポーネントのコードはこちらです。
https://github.com/yananose/VisemeCommandDemo/blob/master/Assets/Omochaya/Scripts/VisemeCommand.cs
上記のファイル(と OVRLipSync)だけ組み込めば、超お手軽に音声操作が実現できます。
とはいえ精度も手軽さに見合ったものではありますので、ご利用はカジュアルに。
バグがありましたらお知らせいただけると嬉しいです。
[2020/01/13] Speech Recognizer、Speech Frameworkについて追記。
[2019/12/14] github に公開しているコンポーネントとデモを更新(認識精度の向上)。