MkItYs

MkItYs > AI・交渉・物語の自動生成 > 

images

ComryUI で、音声認識/音声合成/チャット/感情分析のノードを作る:Docker, Dynamic DNS, SSL/TSL, Python (Transformers, aiohttp), JavaScript, ComfyUI

images

ComfyUI で、音声認識/音声合成/チャット/感情分析のノードを作ってみますーーなおすべてのエンジンは、ローカルで実装したものを使います。[※1][※2]



この動画は音声が出ます(ただし音声認識の箇所は、無音に加工しています)。
生成音声:白上虎太郎(VOICEVOX)
生成画像:Counterfeit V3.0
※1
画像生成を、音声認識/音声合成/チャット/感情分析と組み合わせ、キャラとの対話を実現しますーー感情分析の結果は、音声合成の声色と、画像生成のプロンプトに反映させ、キャラの感情表現に使います。
※2
ただ、実用レベルを目指すものではありませんーーじっさいこの実装は、全体に反応がかなり遅いですしね(これでも動画は途中すこし早送りしてますし)……すべてのエンジンとサーバ〜クライアントを、比較的ちいさな VRAM (16GB) 上で動かしているから、というのもありますが。

関連


音声認識のエンジン/サーバ/クライアントを作る:Python (Transformers, aiohttp), Javascript (MediaRecorder)
音声合成のサーバ/クライアントを作る:Python (aiohttp), JavaScript, VOICEVOX
チャットのエンジン/サーバ/クライアントを作る:Python (Transformers, aiohttp), JavaScript
感情分析のエンジン/サーバ/クライアントを作る:Python (Transformers, aiohttp)
ComfyUI で画像生成 〜 なぜそこにつなぐのか:ComfyUI, Stable Diffusion

検証


クラウド:GCP
コンテナ:Docker
ゲストOS:Ubuntu 22.04
ウェブブラウザ:Chrome
画像生成アプリ:ComfyUI

背景


さいきん ComfyUI が、SSL/TSL 通信に対応しました。

これで(nginx などの)プロキシを使わずに、アプリ単体で SSL/TSL によるやり取りができるようになります。

たとえばウェブブラウザで音声認識をするとき、バックエンドが遠隔にある場合、マイク入力には SSL/TSL が要求されますーーこういった構成を、ComfyUI だけでシンプルに作れるようになったわけですね。

一般:仕様


ComfyUI のノードは、バックエンドを Python で、フロントエンドを JavaScript で書くことができます:

https://docs.comfy.org/essentials/custom_node_server_overview
https://docs.comfy.org/essentials/javascript_overview

また、バックエンド〜フロントエンド間でやり取りするしかけも用意されています:

https://docs.comfy.org/essentials/comms_overview

個別:構成


対応

ここでは ComryUI で、音声認識/音声合成/チャット/感情分析のノードを実装してみます。

なお(先に作った)音声認識/音声合成/チャット/感情分析のエンジンと、それらのサーバ〜クライアントのコー ドを流用しますーーこれらのコードの一部を、ComfyUI のノード向けに書き換えます。[※1][※2]

構成:外部:配置

音声認識/音声合成は、利用者のウェブブラウザでクライアントを動作させ(JavaScript)、ここからパソコンのマイクロフォン/スピーカを使います。

音声認識/音声合成/チャット/感情分析、および ComfyUI のバックエンドのサーバ群は、Docker の個別のコンテナに格納します(ライブラリの競合をふせぐためと、パッケージングを容易にするためです)。

構成:外部:通信

ComfyUI のバックエンドとフロントエンドは、インターネットを介して通信しますーーこのとき、ウェブブラウザの音声認識は SSL/TSL 通信を要求するので、サーバ証明書を導入します。

コンテナどうしは、Docker の内部ネットワークで通信させます。


images

構成:内部

バックエンドのサーバ/クライアントは Python の aiohttp で作成しますーーaiohttp は、単一スレッドで動作する非同期のウェブサーバ/クライアント向けのライブラリです(ComryUI もこれを使っていますし)。

音声認識/チャット/感情分析のエンジンは、Transformers のライブラリを使います。

エンジンに使うそれぞれのモデルは:

音声認識:Wav2Vec の日本語モデル
音声合成:VOICEVOX のコア部分
チャット:LocalNovelLLM-project の言語モデル(量子化版)
感情分析:LUKE 言語モデル & プルチックの8コの感情分類

※1
全体では、次の要素を使うことになります:
機械学習モデルのエンジン部分(音声認識、音声合成、チャット、感情分析):Transformers
コンテナとコンテナネットワーク:Docker
ウェブクライアント+フロントエンド・プログラム(非同期、音声データストリームの送信/受信、録音/再生):Python, JavaScript
ウェブサーバ+バックエンド・プログラム(非同期、音声データストリームの送信/受信):Python
ネームサーバのダイナミックDNS:Bind
※2
ただ前提として、ここでは次の要素もあります(必須ではないのですが、もとの環境がそうなっているので……):
一般ユーザによるコンテナ:Docker rootless
プロキシサーバ:nginx
公式の(自己証明書でなく)認証局による暗号化/認証(ダイナミックDNS/ワイルドカード証明書):Let's Encrypt

個別:実装



バックエンド:呼出
comfyui_xoxxox/__init__.py
from .settx_cmm import *

NODE_CLASS_MAPPINGS = {
  "TxtRep_001": TxtRep_001,
  "SttOut_002": SttOut_002,
  "TtsOut_002": TtsOut_002,
  "LlmTlk_001": LlmTlk_001,
  "SenNum_001": SenNum_001,
  "SenTxt_001": SenTxt_001,
  "SenOrd_001": SenOrd_001,
}

WEB_DIRECTORY = "./web"

バックエンド:本体
comfyui_xoxxox/settxt_cmm.py
import asyncio
from aiohttp import ClientSession
from server import PromptServer

#---------------------------------------------------------------------------
# 音声認識(ウェブブラウザから録音〜テキストを出力)
# デバイス:wav2vec

class SttOut_002:
  @classmethod
  def INPUT_TYPES(s):
    return {
      "required": {
        "numreq": ("INT", {"forceInput": True, "default": 0}),
      },
    }
  RETURN_TYPES = ("STRING",)
  FUNCTION = "anchor"
  CATEGORY = "xoxxox"

  adr001 = "https://<server_remote>:8082/gettxt"

  async def req001(self):
    async with ClientSession() as sssweb:
      async with sssweb.get(self.adr001) as datres:
        dicres = await datres.json()
        return dicres

  def anchor(self, numreq):
    PromptServer.instance.send_sync("sttout_002", {})
    dicres = asyncio.run(self.req001())
    return (dicres["txtres"],)

#---------------------------------------------------------------------------
# 音声合成(テキストを受け取り、ウェブブラウザから音声を出力)
# デバイス:voicevox

class TtsOut_002:
  @classmethod
  def INPUT_TYPES(s):
    return {
      "required": {
        "txtreq": ("STRING", {"forceInput": True, "multiline": True}),
        "numspk": ("INT", {"forceInput": False, "default": 1}),
      },
    }
  RETURN_TYPES = ()
  OUTPUT_NODE = True
  FUNCTION = "anchor"
  CATEGORY = "xoxxox"

  def anchor(self, txtreq, numspk):
    PromptServer.instance.send_sync("ttsout_002", {"txtreq": txtreq, "numspk": numspk})
    return {}

#---------------------------------------------------------------------------
# チャット(テキストを受け取り、チャットのテキストを返す)

class LlmTlk_001:
  @classmethod
  def INPUT_TYPES(s):
    return {
      "required": {
        "txtreq": ("STRING", {"forceInput": True, "multiline": True}),
        "numout": ("INT", {"forceInput": False, "default": 64}),
        "numtmp": ("FLOAT", {"forceInput": False, "default": 0.75}),
        "prmsys": ("STRING", {"forceInput": False, "multiline": True, "default": "あなたは優れた助言者です。"}),
      },
    }
  RETURN_TYPES = ("STRING",)
  FUNCTION = "anchor"
  CATEGORY = "xoxxox"

  adr001 = "http://<server_remote>:8080/"
  
  async def req001(self, dicreq):
      async with ClientSession() as sssweb:
        async with sssweb.post(self.adr001, json=dicreq) as datres:
          dicres = await datres.json()
          return dicres

  def anchor(self, txtreq, numout, numtmp, prmsys):
    dicreq = {"txtreq": txtreq, "numout": numout, "numtmp": numtmp, "prmsys": prmsys}
    dicres = asyncio.run(self.req001(dicreq))
    return (dicres["txtres"],)

#---------------------------------------------------------------------------
# 感情分析(テキストを受け取り、感情を表す数値を返す)

class SenNum_001:
  @classmethod
  def INPUT_TYPES(s):
    return {
      "required": {
        "txtreq": ("STRING", {"forceInput": True, "multiline": True}),
      },
    }
  RETURN_TYPES = ("INT",)
  FUNCTION = "anchor"
  CATEGORY = "xoxxox"

  adr001 = "http://<server_remote>:8081/"

  async def req001(self, dicreq):
    async with ClientSession() as sssweb:
      async with sssweb.post(self.adr001, json=dicreq) as datres:
        dicres = await datres.json()
        return dicres

  def anchor(self, txtreq):
    dicreq = {"txtreq": txtreq}
    dicres = asyncio.run(self.req001(dicreq))
    return (dicres["numres"],)

#---------------------------------------------------------------------------
# 感情分析(感情の数値を受け取り、対応するテキストを返す)

class SenTxt_001:
  @classmethod
  def INPUT_TYPES(s):
    return {
      "required": {
        "numreq": ("INT", {"forceInput": True}),
        "senjoy": ("STRING", {"forceInput": False, "multiline": False, "default": "joy"}),
        "sensad": ("STRING", {"forceInput": False, "multiline": False, "default": "sadness"}),
        "senant": ("STRING", {"forceInput": False, "multiline": False, "default": "anticipation"}),
        "senspr": ("STRING", {"forceInput": False, "multiline": False, "default": "surprise"}),
        "senang": ("STRING", {"forceInput": False, "multiline": False, "default": "anger"}),
        "senfer": ("STRING", {"forceInput": False, "multiline": False, "default": "fear"}),
        "sendis": ("STRING", {"forceInput": False, "multiline": False, "default": "disgust"}),
        "sentrs": ("STRING", {"forceInput": False, "multiline": False, "default": "trust"}),
      },
    }
  RETURN_TYPES = ("STRING",)
  FUNCTION = "anchor"
  CATEGORY = "xoxxox"

  def anchor(self, numreq, senjoy, sensad, senant, senspr, senang, senfer, sendis, sentrs):
    if numreq == 0:
      txtres = senjoy
    elif numreq == 1:
      txtres = sensad
    elif numreq == 2:
      txtres = senant
    elif numreq == 3:
      txtres = senspr
    elif numreq == 4:
      txtres = senang
    elif numreq == 5:
      txtres = senfer
    elif numreq == 6:
      txtres = sendis
    elif numreq == 7:
      txtres = sentrs
    return (txtres,)

#---------------------------------------------------------------------------
# 感情分析(感情の数値を受け取り、対応する数値を返す)

class SenOrd_001:
  @classmethod
  def INPUT_TYPES(s):
    return {
      "required": {
        "numreq": ("INT", {"forceInput": True}),
        "senjoy": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
        "sensad": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
        "senant": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
        "senspr": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
        "senang": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
        "senfer": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
        "sendis": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
        "sentrs": ("INT", {"forceInput": False, "multiline": False, "default": 1}),
      },
    }
  RETURN_TYPES = ("INT",)
  FUNCTION = "anchor"
  CATEGORY = "xoxxox"

  def anchor(self, numreq, senjoy, sensad, senant, senspr, senang, senfer, sendis, sentrs):
    if numreq == 0:
      numres = senjoy
    elif numreq == 1:
      numres = sensad
    elif numreq == 2:
      numres = senant
    elif numreq == 3:
      numres = senspr
    elif numreq == 4:
      numres = senang
    elif numreq == 5:
      numres = senfer
    elif numreq == 6:
      numres = sendis
    elif numreq == 7:
      numres = sentrs
    return (numres,)

#---------------------------------------------------------------------------
# 感情分析(テキストの一部を指定の文字列で置換する)

class TxtRep_001:
  @classmethod
  def INPUT_TYPES(s):
    return {
      "required": {
        "txtbdy": ("STRING", {"forceInput": True, "multiline": True}),
        "txtmod": ("STRING", {"forceInput": True, "multiline": True}),
        "txtorg": ("STRING", {"forceInput": False, "multiline": False, "default": "<>"}),
      },
    }
  RETURN_TYPES = ("STRING",)
  FUNCTION = "anchor"
  CATEGORY = "xoxxox"

  def anchor(self, txtbdy, txtmod, txtorg):
    txtres = txtbdy.replace(txtorg, txtmod)
    return (txtres,)

#---------------------------------------------------------------------------

フロントエンド
comfyui_xoxxox/web/lib/settxt_cmm.js
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";

//--------------------------------------------------------------------------
// ノード(音声認識)
// デバイス:wav2vec
app.registerExtension({
  name: "xoxxox.SttOut_002",
  async setup() {

    const adr001 = 'https://' + location.hostname + ':8082/putsnd'
    const mscrec = 5000

    api.addEventListener("sttout_002", async ({detail}) => {
      const stream = await navigator.mediaDevices.getUserMedia({audio: true});
      const devrec = new MediaRecorder(stream);
      devrec.start();

      let cnksnd = [];
      devrec.ondataavailable = e => {
        cnksnd.push(e.data);
      };

      devrec.onstop = async () => {
        const blbsnd = new Blob(cnksnd, {type: 'audio/webm'});
        await fetch(adr001, {
          method: 'POST',
          headers: {
            'Content-Type': 'audio/webm'
          },
          body: blbsnd
        });
        cnksnd = [];
        stream.getTracks().forEach(itmtrk => itmtrk.stop());
      };

      setTimeout(() => {
        if (devrec.state === 'recording') {
          devrec.stop();
        }
      }, mscrec);
    })

  }
});

//--------------------------------------------------------------------------
// ノード(音声合成)
// デバイス:voicevox
app.registerExtension({
  name: "xoxxox.TtsOut_002",
  async setup() {

    const adr001 = 'https://' + location.hostname + ':8081/';

    const req001 = async (adr001, dicreq) => {
      const stsres = await fetch(adr001, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(dicreq)
      });
      if (stsres.ok) {
        const blbado = await stsres.blob();
        const adrado = URL.createObjectURL(blbado);
        const objado = new Audio(adrado);
        objado.play();
      }
      else {
        alert('err: ' + stsres.statusText);
      }
    }

    api.addEventListener("ttsout_002", ({detail}) => {
      const dicreq = detail;
      req001(adr001, dicreq);
    })

  }
});