凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

mmd.gl.enchant.js を使ってミクさんと音声認識で会話してみた

はじめに

本記事は、東京 Node 学園祭 2012 の LT 枠 - 6番目で発表したデモ内容の紹介になります。スライドは以下になります。

(2012/11/25 20:57 貼り間違えたので修正)

構成としては、発表で紹介した Julius の Node.js アドオンから得た音声認識結果を Socket.io 経由でクライアント(ブラウザ)へ送り、mmd.gl.enchant.js で表示しているミクさんを動かす、という形になります。イメージとしては、MMDAgent みたいなことが出来る感じです。

デモ内容


話しかけると考えこんで、解釈するとお辞儀しながらふきだしで解釈した内容を表示してくれるようにしています。
リモコンとつなぎこめば実際に家電が操作されます。

mmd.gl.enchant.js の導入

参考

まず、enchant.js 本家から Download をクリックして一式落としてきます。解凍したディレクトリ下を辿ると、 examples/plugins/gl/mmd 内に mmd.gl.enchant.js のサンプルが入っています。この中に入っている readme に書いてある通りに準備をします。
まず、MMD を動かす本体である MMD.js を落としてきます。

$ git clone https://github.com/edvakf/MMD.js.git

そして必要なファイルをコピーします。

$ mkdir libs 
$ cp MMD.js/libs/DataView.js MMD.js/libs/sjis.js MMD.js/src/MMD.Model.js MMD.js/src/MMD.Motion.js libs/.

最後にモデルデータ / モーションデータ / トゥーン用テクスチャを追加します。

$ cp -r MMD.js/model MMD.js/motion .
$ cp MMD.js/data/* model/.

これでディレクトリを apache なり nginx なり express なりでアクセスできるようにしてブラウザから開いてみると次のようにミクさんが踊っている様子が表示できます。

なお、モデルデータやモーションデータは有志の方が作って配布してくださっているものがたくさんあるので、豊富な表現が可能だと思います。私は MMDAgent の Sample Script のものをお借りしました。

mmd.gl.enchant.js で繰り返しモーションの追加

mmd.gl.enchant.js の使い方は main.js を見ると基本的なことが分かります。

main.js

まず、モデルデータ、モーションデータをロードします。

var MODEL_PATH = 'model/Miku_Hatsune_Metal.pmd';
var MOTION_PATH = 'motion/kishimen.vmd';
game.preload(MODEL_PATH, MOTION_PATH);

そして、3D モデルを生成してモーションを付加します。

var miku = game.assets[MODEL_PATH].clone();
miku.pushAnimation(game.assets[MOTION_PATH]);

pushAnimation を複数繰り返すと、前のモーションが終わったら次のモーションへ、とキューで処理されていきます。キューが空になると止まります。なので待ちぼうけしている間など、プラプラしていて欲しい場合は無限個突っ込まなければなりません。そこで、ループ機能を追加するために、上のディレクトリへ上り plugins/mmd.gl.enchant.js の中身を覗いてみます。

mmd.gl.enchant.js : 211行目
/**
 * Add animation.
 * Animation will be played in the order that it is added.
 * @param {enchant.gl.mmd.MAnimation} animation
 */
pushAnimation: function(animation) {
	this.animation.push({ frame: 0, animation: animation });
},

pushAnimation では animation 配列にデータを突っ込んでいるだけのようです。この animation 配列はどこで処理してるかというと、直前の initialize メソッドの enterframe イベントハンドラ内で処理しています。

mmd.gl.enchant.js : 183行目
this.addEventListener('enterframe', function() {
	var first;
	var skeleton = this.skeleton;
	var morph = this.morph;
	if (this.animation.length === 0) {
	} else {
		first = this.animation[0];

		var data = first.animation._tick(first.frame);
		first.frame++;

		this._skinning(data.poses);

		this._morphing(data.morphs);

		if (first.frame > first.animation.length) {
			first = this.animation.shift();
			if (this.loop) {
				first.frame = 0;
				this.animation.push(first);
			}
		}
	}
}

で、ループさせたいモーションを loop プロパティを見て判断できるように、次の部分のコードを改変します。

		if (first.frame > first.animation.length) {
			if (first.animation.loop) { // この辺りを追加
				first.frame = 0;
			} else {
				first = this.animation.shift();
				if (this.loop) {
					first.frame = 0;
					this.animation.push(first);
				}
			}
		}

これで次のようにしてループモーションを追加することができます。

var motion = game.assets[MOTION_PATH];
motion.loop = true;
miku.pushAnimation(motion);

またモーションの削除には clearAnimation というメソッドが用意されているようですので、これらを組み合わせればいい感じに動いてくれます。

miku.clearAnimation();

mmd.gl.enchant.js と音声認識のつなぎ込み

最後に音声認識部分とのつなぎ込みを行います。サーバ側では、Socket.io とつなげるだけです。

サーバ側
var Julius  = require('julius')
  , grammar = new Julius.Grammar()
  , io      = require('socket.io').listen(12345)
  , socket  = null
;

// 覚えさせたい言葉
grammar.add('(テレビ|エアコン|モニタ)を?(つけて|消して)');

// 誤認識対策(ゴミワードを混ぜておく)
var gomi = 'あいうえおかきくけこさしすせそ';
for (var i = 0; i < gomi.length; ++i) {
	for (var j = 0; j < gomi.length; ++j) {
		grammar.add(gomi[i] + gomi[j]);
	}
}

grammar.compile(function(err, result) {
	if (err) throw err;
	var julius = new Julius( grammar.getJconf() );
	julius.on('result', function(str) {
		console.log(str);
		if (str === '' || str.length === 2) return;

		var result = (/て$/.test(str) ? str.slice(0, -1) + 'ます' : str);
		if (socket !== null) {
			socket.emit('result', { str: result });
		}
	}).on('speechStart', function() {
		if (socket !== null) {
			socket.emit('speech', { state: true });
		}
	}).on('speechStop', function() {
		if (socket !== null) {
			socket.emit('speech', { state: false });
		}
	});
	julius.start();
});

io.sockets.on('connection', function (s) {
	socket = s;
	socket.on('disconnect', function() {
		socket = null;
	});
});
クライアント側
enchant();

var game, miku, tweet;

// PMDファイルのパス
var MODEL_PATH = 'model/Miku_Hatsune.pmd';

// VMDファイルのパス
var MOTION_PATH = {
	boredom  : 'motion/mmdagent/mei_idle/mei_idle_boredom.vmd',
	think    : 'motion/mmdagent/mei_idle/mei_idle_think.vmd',
	yawn     : 'motion/mmdagent/mei_idle/mei_idle_yawn.vmd',
	laugh    : 'motion/mmdagent/mei_laugh/mei_laugh.vmd',
	breath   : 'motion/mmdagent/mei_breath/mei_breath.vmd',
	left     : 'motion/mmdagent/mei_imagine/mei_imagine_left.vmd',
	right    : 'motion/mmdagent/mei_imagine/mei_imagine_right.vmd',
	greeting : 'motion/mmdagent/mei_greeting/mei_greeting.vmd'
};
var i = 0;

var socket = io.connect('http://localhost:12345');
socket.on('result', function (data) {
	console.log(data.str);

	if (tweet) {
		if (tweet) tweet.remove();
		tweet = null;
	}

	tweet = new TTweet(600, 64, TTweet.BOTTOM, TTweet.CENTER);
	tweet.text(data.str);
	tweet.x = 700;
	tweet.y = 30;
	game.rootScene.addChild(tweet);
	setTimeout(function(){
		if (tweet) tweet.remove();
		tweet = null;
	}, 3000);

	miku.clearAnimation();
	var greet = game.assets[MOTION_PATH['greeting']];
	greet.loop = false;
	var idle  = game.assets[MOTION_PATH['boredom']];
	idle.loop = true;

	miku.pushAnimation(greet);
	miku.pushAnimation(idle);
}).on('speech', function (data) {
	if (data.state === true) {
		miku.clearAnimation();
		var greet = game.assets[MOTION_PATH['think']];
		greet.loop = false;
		var idle  = game.assets[MOTION_PATH['boredom']];
		idle.loop = true;

		miku.pushAnimation(greet);
		miku.pushAnimation(idle);
	}
});

window.onload = function(){
	game = new Game(2000, 1000);
	// v0.2.1よりgame.preloadから読み込めるようになった.
	game.preload(MODEL_PATH);
	for (var x in MOTION_PATH) {
		game.preload(MOTION_PATH[x]);
	}
	game.onload = function() {

		var scene = new Scene3D();
		scene.backgroundColor = '#ffffff';

		var camera = scene.getCamera();
		camera.y = 20;
		camera.z = 80;
		camera.centerY = 10;

		// PMDファイル読み込み.
		// colladaの読み込みと同様にcloneかsetして使用する.
		miku  = game.assets[MODEL_PATH].clone();
		scene.addChild(miku);

		// VMDファイル読み込み
		// miku.pushAnimation(game.assets[MOTION_PATH1]);
		for (var x in MOTION_PATH) {
			var motion = game.assets[MOTION_PATH[x]];
			motion.loop = true;
			miku.pushAnimation(motion);
		}

		var oldX = 0;
		r = Math.PI / 2;

		game.rootScene.addEventListener('touchstart', function(e) {
			oldX = e.x;
		});
		game.rootScene.addEventListener('touchmove', function(e) {
			r += (e.x - oldX) / 100;
			camera.x = Math.cos(r) * 80;
			camera.z = Math.sin(r) * 80;
			oldX = e.x;
		});

		var label = new Label('0');
		label.font = '24px helvetica';
		game.rootScene.addChild(label);
		var c = 0;
		setInterval(function() {
			label.text = c;
			c = 0;
		}, 1000);
		game.rootScene.addEventListener('enterframe', function(e) {
			c++;
		});

	};
	game.start();
};

吹き出し用のクラスは以下の場所からお借りしてコピペして使用しています。

おわりに

特に大したこともせずに 3D アバターとのつなぎ込みが出来てしまうのは素晴らしいですね。次は喋らせるところ頑張ってみようと思います。今の仕組みだと無理そうだけれど、リップシンクとか出来るのかな…。