凹みTips

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

uLipSync x WebGL における再生方法の制限について

はじめに

先日、uLipSync の WebGL ビルドでランタイム生成した際、音声は再生されるもののリップシンクしない、という報告を受けて調査をしてみました。

tips.hecomi.com

原因解説

原因としては AudioSource.PlayOneShot() の利用でした。こちらを詳しく解説していきます。

WebGL 向けビルドでは、MonoBehaviour.OnAudioFilterRead()AudioSource.GetOutputData() といった API を通じて、そのタイミングで再生している音のバッファを取得することが出来ます。しかしながら、これらは一つ前の記事で触れましたが Unity のサウンド周りの仕組み及び WebGL の制約により使用することが出来ません。代わりに、WebGL ビルドでは、データから直接バッファ取得可能な AudioClip.GetData() を通じてリップシンクを実現しています。この中で、uLipSync では目的の AudioSource に対して現在セットされている AudioClip を取得し、それに対して GetData() を行っています。

さて、PlayOneShot() の話題に戻ります。この API は現在セットされている AudioClip を再生する AudioSource.Play() と異なり、引数で指定された AudioClip を再生します。そして連続で再生した場合は、前に再生されているものはそのままに重複して音を鳴らすことが出来ます。一方で、これらの音はキャンセルしたりポーズしたりすることは出来ません。

docs.unity3d.com

つまり、PlayOneShot では呼び出し時には AudioSource.clip プロパティを更新せずに内部的に AudioClip をリストとして保持し、そこで直接管理を行うような処理となります。この結果 AudioSource.clip 経由での GetData() ではデータが降ってこず、リップシンクが出来ていなかった、という状態になっていました。

修正方法

PlayOneShot() を次のように Play() に変えることで動作するようになります。

var source = GetComponent<AudioSource>();

if (oneshot)
{
    // こちらでは動作せず
    source.PlayOneShot(clip);
}
else
{
    // clip を指定して Play で動作
    source.clip = clip;
    source.loop = false;
    source.Play();
}

PlayOneShot() では重複で音が鳴りえますので、リップシンク、というコンテキストの上では Play() の方が良いかもしれません。

調査メモ

備忘録のために調査メモです。C# 側は以下のようなコードを書きました。URL を InputField から取ってきて、それを Play または PlayOneShot する、というものです。

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Networking;
using System.Collections;

namespace uLipSync.Samples
{

[RequireComponent(typeof(AudioSource))]
public class RuntimeAudioClipPlayer : MonoBehaviour
{
    public InputField inputField;
    
    public void Play()
    {
        StartCoroutine(DownloadAndPlay(false));
    }
    
    public void PlayOneShot()
    {
        StartCoroutine(DownloadAndPlay(true));
    }
    
    IEnumerator DownloadAndPlay(bool oneshot)
    {
        var source = GetComponent<AudioSource>();
        if (!source) yield return null;
        
        var url = inputField.text;
        var type = AudioType.WAV;
        using var www = UnityWebRequestMultimedia.GetAudioClip(url, type);
        yield return www.SendWebRequest();
        
        var clip = DownloadHandlerAudioClip.GetContent(www);
        clip.name = inputField.text;
        
        if (oneshot)
        {
            source.PlayOneShot(clip);
        }
        else
        {
            source.loop = false;
            source.clip = clip;
            source.Play();
        }
    }
}

}

テスト用に WAV ファイルをホスティングするためのサーバも用意しておきます。以下は Node.js で起動引数で指定したディレクトリをホストするコードです。CORS に対応するためにいくつかヘッダを追加する必要がある点に注意です。

const http = require('http');
const fs = require('fs');
const path = require('path');

const basePath = process.argv[2] ? path.resolve(process.argv[2]) : __dirname;

const server = http.createServer((req, res) => {
  const filePath = path.join(basePath, req.url);
  const fileExtension = path.extname(filePath);

  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept');

  if (fileExtension !== '.wav') {
    res.writeHead(404);
    res.end('404 Not Found');
    return;
  }

  fs.stat(filePath, (err, stats) => {
    if (err || !stats.isFile()) {
      res.writeHead(404);
      res.end('404 Not Found');
      return;
    }

    res.writeHead(200, {'Content-Type': 'audio/wav'});
    fs.createReadStream(filePath).pipe(res);
  });
});

const PORT = 3000;
server.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
  console.log(`Serving files from: ${basePath}`);
});

これで PlayOneShot 側では口パクせず、Play 側では動作するのが確認できました。

おわりに

これで uLipSync の WebGL 対応に関しての大体の問題点は修正できたかもしれません?

理想的には音の取得部分の AudioWorklet 化を行い、Web Worker 側で解析処理を行う、というコードにまで出来れば最高ですが…、自分自身のユースケースがないのでひとまず動作するようになったここまでで留めておきます。