
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);
})
}
});
