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 の内部ネットワークで通信させます。
- ◯
- 構成:内部
バックエンドのサーバ/クライアントは 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); }) } });