Help us understand the problem. What is going on with this article?

ブラウザだけでVtuber収録 - Teraconnectを支える技術

More than 1 year has passed since last update.

はじめに

Teraconnect というバーチャルアバターで授業を収録・公開するWebサービスをつくっています。
https://authoring.teraconnect.org/
teraconnect

この記事では Teraconnect のコア部分の実装を紹介します。

一般的なWebサービスではあまり使われない感のある

  • WebGL(アバターの描画)
  • WebAudio(音の録音)

あたりの感じがなんとなく伝われば幸いです。

アバターを表示する

なにはなくともアバターです。デファクトの地位を確立した感のあるVRMを採用しています。

VRMは、gLTFという3Dファイルの規格を元にしています。そのため下記のようなWebサービスへVRMファイルを放り込んでやれば、モデルを表示することができます。
https://gltf-viewer.donmccurdy.com/

JSの3Dライブラリはいくつかありますが、上記のサービスでは three.js が使用されています。公式リポジトリのexamplesにあるGLTFLoader.jsでVRMの読み込みができた1ので、Teraconnect でもこれを使うことにしました。

以下のコードで、VRMのアバターを表示することができます。

import * as THREE from 'three'
import './GLTFLoader'

const windowWidth = window.innerWidth
const windowHeight = window.innerHeight

const camera = new THREE.PerspectiveCamera(1, windowWidth / windowHeight, 1, 200)
camera.position.set(0, 1.4, 150)
camera.lookAt(new THREE.Vector3(0, 1.1, 0)

const scene = new THREE.Scene()

const light = new THREE.AmbientLight(lightColor)
light.position.set(0, 1, 0)
scene.add(light)

// GLTFLoaderはURLを受け付けるので、VRMファイルのblobをObjectURLにして渡す
const avatarURL = window.URL.createObjectURL(blob)
const vrm = await new Promise(resolve => {
    new THREE.GLTFLoader().load(avatarURL, vrm => {
        resolve(vrm)
    })
})
scene.add(vrm.scene)

const renderer = new THREE.WebGLRenderer({antialias: true})
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(windowHeight, windowHeight)
renderer.gammaOutput = true
renderer.render(scene, camera)

document.body.appendChild(renderer.domElement)

アバターを動かす

three.js には豊富な関数が用意されており、3Dオブジェクトを簡単に操作することが可能です。

ボーンを動かす

3Dオブジェクトの rotation にラジアンを指定することで、回転角を変更できます。腕の角度をTポーズから下45°に変更してみます。

// 扱いやすいよう、各オブジェクトのボーンをまとめる
const bones = {}
vrm.scene.traverse(object => {
    if (object.isBone) {
        bones[object.name] = object
    }
})

const zRadian = 45 * Math.PI / 180
bones.J_Bip_L_UpperArm.rotation.z = zRadian
bones.J_Bip_L_UpperArm.rotation.set(0, 0, zRadian)// 3軸まとめて指定も可能

モーフィングを行う

VRoid Studio で書き出したVRMは、顔パーツに多数のブレンドシェイプが設定されています。ウェイトを指定することで、これらを利用できます。

const faceIndexes = ['AllAngry', 'AllFun', 'AllJoy', 'AllSorrow', 'AllSurprised', 'BrwAngry', 'BrwFun', 'BrwJoy', 'BrwSorrow', 'BrwSurprised', 'EyeAngry', 'EyeClose', 'EyeCloseR', 'EyeCloseL', 'EyeJoy', 'EyeJoyR', 'EyeJoyL', 'EyeSorrow', 'EyeSurprised', 'EyeExtra', 'MouthUp', 'MouthDown', 'MouthAngry', 'MouthCorner', 'MouthFun', 'MouthJoy', 'MouthSorrow', 'MouthSurprised', 'MouthA', 'MouthI', 'MouthU', 'MouthE', 'MouthO', 'Fung1', 'Fung1Low', 'Fung1Up', 'Fung2', 'Fung2Low', 'Fung2Up', 'EyeExtraOn']
const faceMesh = vrm.scene.children[0]
faceMesh.morphTargetInfluences[faceIndexes.indexOf('AllJoy')] = 0.5 // 値は0〜1をとる

アバターをアニメーションさせる

直接値を指定する以外に、アニメーションをJSONオブジェクトから生成することができます。

ボーンをアニメーションクリップから動かす

腕をぱたぱた動かすアニメーションを作ってみます。キーとして回転(rot)、位置(pos)、縮尺(scl)が使用できますが、rotの値はラジアンではなくクォータニオンになります。

const zRadian = 45 * Math.PI / 180
const armEuler = new THREE.Euler(0, 0, zRadian, 'XYZ')
const unitQuaternion = [0, 0, 0, 1]
const armQuaternion = (new THREE.Quaternion).setFromEuler(armEuler).toArray()

const bones = [bones.J_Bip_L_UpperArm]
const keys = [
    {
        keys: [
            { rot: unitQuaternion, time: 0 },
            { rot: armQuaternion, time: 0.5 },
            { rot: unitQuaternion, time: 1.0 }
        ]
    }
]

const clip = THREE.AnimationClip.parseAnimation(
    {
        name: 'armRotation',
        hierarchy: keys
    },
    bones
)

const bodyMesh = vrm.scene.children[1]
const animationMixer = new THREE.AnimationMixer(bodyMesh)
animationMixer.clipAction(clip)

モーフィングをアニメーションクリップから動かす

5秒に一回、まばたきをするアニメーションを作ってみます。値の補間が行われるので、4.8秒時点に0を挿入しています。これがないと0秒〜4.9秒の間で、ゆっくりまぶたが閉じてしまいます。

tracksには複数のアニメーショントラックを格納できますが、 times配列の最後の値が全てのトラックで同じでなければ動作しません。

const tracks = []
const targetIndex = faceIndexes.indexOf('EyeClose')
tracks.push({
    name: `.morphTargetInfluences[morphTarget${targetIndex}]`,
    type: 'number',
    times: [0, 4.8, 4.9, 5.0]
    values: [0, 0, 1, 0]
})

const clip = THREE.AnimationClip.parse({
    name: 'eyeBlink',
    tracks: tracks
})

const skinMesh = vrm.scene.children[0]
const animationMixer = new THREE.AnimationMixer(skinMesh)
animationMixer.clipAction(clip)

アニメーションクリップの再生

アニメーションを作成したら、requestAnimationFrameで毎フレーム更新することでアニメーションを再生できます。自前で deltaTime を求めても良いですが、three.js の Clockが便利です。

const clock = new THREE.Clock(true)

function animate() {
    requestAnimationFrame(animate)

    animationMixer.update(clock.getDelta())
    renderer.render(scene, camera)
}

動作を反映する

先生の目や口、首の動きを反映して、より実在感の高い授業にします。

顔のパーツ検出(Facial Landmark Detection)の分野では、Deep Learningではなく特徴量を使うライブラリが多い2ようです。JSのライブラリでは Beyond Reality Face というものがあり、実用的な速度かつ精度だったのでこれを導入することにしました。

Beyond Reality Face v4
https://tastenkunst.github.io/brfv4_javascript_examples/

Webカメラから640x480の画像データを渡してやると、各パーツの座標やスケール値が取得でき、顔の3軸まで返ってきます。複数の顔検出にも対応しているようです。

brfManager.update(imageDataCtx.getImageData(0, 0, 640, 480).data);
const face = brfManager.getFaces()[0]

首の角度をアバターに反映してみます。値はラジアンなので、そのままrotation.setに渡すことができます。

const neckRadians = [-face.rotationX, face.rotationY, -face.rotationZ] // アバターへ鏡像として反映するため、一部の符号を反転
bones.J_Bip_C_Neck.rotation.set(...neckRadians)

口の開き具合から母音を推定して、リップシンクも可能です。

const width = mouthWidth(face.scale, face.points)
const height = mouthHeight(face.scale, face.points)
if (height / width > 0.9 && width < 0.35) {
    faceMesh.morphTargetInfluences[faceIndexes.indexOf('MouthO')] = 1.0
}

これらの処理はパフォーマンス確保のため、Web Workerで実行しています。

実は当初、posenet を使用していたのですが、腕がガクガクと暴れるので削除してしまいました。posenet の学習済みモデルはCOCOをベースにしており、屋内の四肢が見切れるような画像が少ないようです。自前でバストアップ画像を再学習させると改善できるかもしれませんが、BRFv4 と併用するとCPU負荷が高くなりすぎるので、今回はやめにしました。

声を記録する

Web Audio は仕様が安定せず、ScriptProcessorNode -> AudioWorkers -> AudioWorklet とAPIが変遷してきました。現在は Chrome と Firefox、Opera で AudioWorklet がサポートされており、 Teraconnect でも採用することにしました。

Worklet の呼び出し側では、マイクの音声ストリームを Worklet Node へ接続し、処理が返ってきたら Cloud Storage へ音声ファイルをアップロードしています。

async initRecorder() {
    const context = new AudioContext()
    await context.audioWorklet
        .addModule('/voiceRecorderProcessor.js')
        .catch(err => {
            console.error(err)
        })

    const recorder = new AudioWorkletNode(context, 'recorder')
    const stream = await window.navigator.mediaDevices.getUserMedia({
        audio: {
            echoCancellation: true,
            autoGainControl: true,
            noiseSuppression: true
        },
        video: false
    })
    const micInput = context.createMediaStreamSource(stream)
    micInput.connect(recorder)
    recorder.connect(context.destination)

    recorder.port.onmessage = event => {
        uploadVoice(event.data) // 音声ファイルをアップロード
    }
}

Worklet 側です。バッファに音声データを貯めておき、一定時間無音が続いたらその時点までのデータを呼び出し側へ返すようにしています。

class Recorder extends AudioWorkletProcessor {
    process(allInputs) {
        const inputs = allInputs[0][0] // モノラル録音

        if (this._isSilence(inputs)) {
            if (this._shouldSaveRecording()) {
                this._saveRecord()
                return true
            }

            if (this._silenceBeginSecond === 0) {
                this._silenceBeginSecond = this._elapsedSecondFromStart()
            }
            if (this._buffers.length > 0) {
                this._recordInput(inputs)
            } else {
                this._heapQuietInput(inputs)
            }
        } else {
            this._silenceBeginSecond = 0
            this._recordQuietInput()
            this._recordInput(inputs)
        }

        return true
    }

    _saveRecord() {
        this.port.postMessage({
            speechedAt: this._voiceBeginSecond,
            durationSec: this._durationSecond(),
            buffers: this._buffers,
            bufferLength: this._bufferLength
        })
        this._clearRecord()
    }
}

音量の計算は以下のようなロジックになっています。その他の関数は、ほとんどが単純に値を格納しているだけです。

_volumeLevel(inputs) {
    let sum = 0.0
    inputs.forEach(input => {
        sum += Math.pow(input, 2)
    })

    return Math.sqrt(sum / inputs.length)
}

音声認識で文字起こしする

Cloud Functions から Google Cloud Speech-to-Text を使用し、音声認識による文字起こしを行います。wav ファイルを16KHzに変換してバケットに書き込むと、それをトリガーに処理が走るようにしています。

exports.wavToText = async (file, _) => {
    const request = {
        audio: {
            uri: `gs://${file.bucket}/${file.name}`
        },
        config: {
            encoding: 'LINEAR16',
            sampleRateHertz: 16000,
            languageCode: 'ja-JP',
        },
    }

    const speech = require('@google-cloud/speech')
    const client = new speech.v1.SpeechClient()
    const response = await client.recognize(request)[0]
    const transcription = response.results.map(result => result.alternatives[0].transcript).join('\n')
}

精度はギリギリ実用レベルという感じで、意識的にはっきり喋らないと、割ととんでもない結果が返ってきます。

ちなみに Teraconnect では、このAPIの費用が最も高いです。Chromeでは SpeechRecognition が使用できるので、今後はこちらの導入を進めていく予定です。

おわりに

コア部分だけをざっくり紹介しましたが、どれも使う分にはそう難しくない技術ばかりです。
しかし仕事を辞めて制作に没頭していたら、いつの間にか貯金が底を尽きかけていました。
EdTech や three.js、WebRTC(リアルタイム配信)などに携われる職場を、地域問わず探しています。
よろしくおねがいします(切実

就職先がぶじ決まりました…!


  1. GLTFLoader.jsは2018/08/07日時点のもの。モジュールとして読み込む際は一行目にimport * as THREE from 'three'の追加が必要です。ただ、このファイルは頻繁にアップデートされているようで、今最新のものを使ってみたらエラーになってしまいました。 

  2. 例えばUnityのアセットDlib FaceLandmark DetectorではHOG特徴量を使用しています。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away