├── .gitignore ├── LICENSE ├── README.md ├── aivtuber.css ├── aivtuber.js ├── index.html └── publish_agent.png /.gitignore: -------------------------------------------------------------------------------- 1 | chara.png 2 | chara_blinking.png 3 | background.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 makunugi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # これは何か 2 | チャット欄に寄せられるコメントに自動で応答をしながら進行をする、AIによるYouTube LIVE配信を行うためのサンプルです。 3 | 4 | # 準備 5 | 6 | AIによる雑談配信を行うためのソースコードはすでに実装済みです。 7 | 下記の準備が必要な項目のみ設定が必要です。 8 | 9 | ## 1. 画像の用意 10 | 下記の3つの名前の画像を用意し、ルートディレクトリ内に設置してください。 11 | 12 | - chara.png 13 | - chara_blinking.png 14 | - background.png 15 | 16 | ※`chara_blinking.png`は瞬きをしているキャラクターの静止画です。 17 | ※`background.png`は背景の画像です。 18 | 画像の名前を変更したい場合は、適宜index.html、aivtuber.js内を変更してください。 19 | 20 | ## 2. meboのAPIキー・エージェントIDの設定 21 | [mebo](https://mebo.work)を利用して、会話が可能なAIキャラクターを作成してください。 22 | mebo内の[Chara.AI Generator](https://zenn.dev/makunugi/articles/ebecbb5de562d6)という機能を利用すると、スムーズにAIキャラクターが作成できます。 23 | 24 | AIキャラクターを作成したら、meboの公開設定画面でAIキャラクターを限定公開し、「APIを有効化」してください。 25 | 26 | APIを有効化するとAPIキーとエージェントIDを取得できます。 27 | 28 | APIキーを取得したら、`aivtuber.js`を開き、下記の箇所にAPIキーとエージェントIDを入力しましょう。 29 | 30 | ```js 31 | const MEBO_API_KEY = ""; 32 | const MEBO_AGENT_ID = ""; 33 | ``` 34 | 35 | ## 3. VOICEVOXをインストール 36 | 声の読み上げはVOICEVOXを利用します。 37 | 下記からVOICEVOXをインストールし、起動してください。VOICEVOXが起動されることで、ローカル環境にAPIが立ち上がります。 38 | [VOICEVOX公式サイト](https://voicevox.hiroshiba.jp/) 39 | 40 | 41 | ```js 42 | const VOICE_VOX_API_URL = "http://localhost:50021"; 43 | ``` 44 | デフォルトで上記が`aivtuber.js`に設定されています。ポート番号を変更する際は、上記のURLを適宜変更してください。 45 | 46 | 尚、VOICEVOXを利用してYouTube配信をする場合は、ライセンス表記が必要です。概要欄などできちんと明記をして利用しましょう。 47 | [VOICEVOX利用規約](https://voicevox.hiroshiba.jp/term/) 48 | 49 | ## 4. YouTubeライブ配信のVIDEO IDを設定 50 | YouTubeのライブ配信の準備が整ったら、ライブ配信の動画のURLに末尾にあるVideo IDを`aivtuber.js`の下記の箇所に入力してください。 51 | 52 | ```js 53 | const YOUTUBE_VIDEO_ID = ''; 54 | ``` 55 | 56 | Video IDは動画のURLの末尾にある「v=」より後の文字列です。 57 | `https://www.youtube.com/watch?v=x12345667` 58 | 上記であれば、Video IDは「x12345667」になります。 59 | 60 | 61 | ## 5. YouTube Data APIのAPIキーの用意 62 | YouTubeライブ配信のコメントを取得するため、YouTube Data APIのAPIキーを利用します。 63 | APIキーの取得方法は、[こちら](https://qiita.com/shinkai_/items/10a400c25de270cb02e4#:~:text=%E8%AA%8D%E8%A8%BC%E6%83%85%E5%A0%B1%E3%81%AE%E4%BD%9C%E6%88%90%EF%BC%88API%E3%82%AD%E3%83%BC%E3%81%AE%E4%BD%9C%E6%88%90%EF%BC%89,-%E3%80%8C%E8%AA%8D%E8%A8%BC%E6%83%85%E5%A0%B1%E3%80%8D%E3%82%92&text=%E3%80%8C%E8%AA%8D%E8%A8%BC%E6%83%85%E5%A0%B1%E3%82%92%E4%BD%9C%E6%88%90%E3%80%8D%E3%82%92,%E4%BF%9D%E5%AD%98%E3%80%8D%E3%82%92%E3%82%AF%E3%83%AA%E3%83%83%E3%82%AF%E3%81%97%E3%81%BE%E3%81%99%E3%80%82)の記事が大変わかりやすくまとめられていました。 64 | 65 | APIキーを取得したら、`aivtuber.js`の下記の箇所に入力しましょう。 66 | 67 | ```js 68 | const YOUTUBE_DATA_API_KEY = ''; 69 | ``` 70 | 71 | # 動作確認 72 | `index.html`をブラウザで開きましょう。 73 | ページ下部のテキスト入力欄にコメントを入力し「送信」ボタンを押して、無事応答が返ってくれば成功です。 74 | 75 | # LINE開始 76 | 動作確認が完了したら、「LIVE開始」を押しましょう。 77 | YouTube LIVEのコメントに対して応答を返すようになります。 78 | [OBS](https://obsproject.com/ja/download)などの画面配信が可能なツールを利用して、AI VTuberを表示しているブラウザのキャプチャをYouTubeに配信しましょう。 79 | 80 | -------------------------------------------------------------------------------- /aivtuber.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | background-image: url('background.png'); 4 | background-size: cover; 5 | margin: 0; 6 | overflow: hidden; 7 | color: #FFFFFF; 8 | } 9 | 10 | div.aiResponseBox { 11 | position: absolute; 12 | top: 10%; 13 | width: 70%; 14 | right: 20%; 15 | } 16 | 17 | div.bottomBox { 18 | position: absolute; 19 | bottom: 0%; 20 | width: 80%; 21 | margin-left: 2%; 22 | margin-right: 10%; 23 | } 24 | 25 | div { 26 | text-align: center; 27 | } 28 | 29 | .ai-response { 30 | font-size: 2.5rem; 31 | font-weight: 600; 32 | line-height: 1.4em; 33 | padding: 5px; 34 | background-color: rgba(0, 0, 0, 0.5); 35 | } 36 | 37 | #userComment { 38 | text-align: center; 39 | font-size: 24px; 40 | margin: 2rem; 41 | } 42 | 43 | #typewriter::after { 44 | content: "|"; 45 | animation-name: blink; 46 | animation-duration: 1s; 47 | animation-iteration-count: infinite; 48 | } 49 | 50 | #vtuber { 51 | position: absolute; 52 | bottom: -3%; 53 | right: 15%; 54 | width: 100%; 55 | text-align: right; 56 | } 57 | 58 | @keyframes blink { 59 | from { 60 | opacity: 0; 61 | } 62 | 63 | to { 64 | opacity: 1; 65 | } 66 | } -------------------------------------------------------------------------------- /aivtuber.js: -------------------------------------------------------------------------------- 1 | //TODO: meboの定数 2 | const MEBO_API_KEY = ""; 3 | const MEBO_AGENT_ID = ""; 4 | 5 | // TODO: VOICEVOXのURL (デフォルトの設定の場合は変える必要なし) 6 | const VOICE_VOX_API_URL = "http://localhost:50021"; 7 | 8 | // TODO: ライブ配信するYouTubeのVideoID 9 | const YOUTUBE_VIDEO_ID = ''; 10 | // TODO: YouTube Data APIを利用可能なAPIKEY 11 | const YOUTUBE_DATA_API_KEY = ''; 12 | 13 | // コメントの取得インターバル (ms) 14 | const INTERVAL_MILL_SECONDS_RETRIEVING_COMMENTS = 10000; 15 | // QUEUEに積まれたコメントを捌くインターバル (ms) 16 | const INTERVAL_MILL_SECONDS_HANDLING_COMMENTS = 3000; 17 | 18 | // VOICEVOXのSpeakerID 19 | const VOICEVOX_SPEAKER_ID = '10'; 20 | 21 | var audio = new Audio(); 22 | // 処理するコメントのキュー 23 | var liveCommentQueues = []; 24 | // 回答済みのコメントの配列 25 | var responsedLiveComments = []; 26 | // VTuberが応答を考え中であるかどうか 27 | var isThinking = false; 28 | // ライブごとに設定する識別子 29 | var LIVE_OWNER_ID = createUuid(); 30 | // NGワードの配列 31 | var ngwords = [] 32 | // YouTube LIVEのコメント取得のページング 33 | var nextPageToken = ""; 34 | // コメントの取得が開始されているかどうかのフラグ 35 | var isLiveCommentsRetrieveStarted = true; 36 | 37 | const getLiveChatId = async () => { 38 | const response = await fetch('https://youtube.googleapis.com/youtube/v3/videos?part=liveStreamingDetails&id=' + YOUTUBE_VIDEO_ID + '&key=' + YOUTUBE_DATA_API_KEY, { 39 | method: 'get', 40 | headers: { 41 | 'Content-Type': 'application/json' 42 | } 43 | }) 44 | const json = await response.json(); 45 | if (json.items.length == 0) { 46 | return ""; 47 | } 48 | return json.items[0].liveStreamingDetails.activeLiveChatId 49 | } 50 | 51 | const getLiveComments = async (activeLiveChatId) => { 52 | const response = await fetch('https://youtube.googleapis.com/youtube/v3/liveChat/messages?liveChatId=' + activeLiveChatId + '&part=authorDetails%2Csnippet&key=' + YOUTUBE_DATA_API_KEY, { 53 | method: 'get', 54 | headers: { 55 | 'Content-Type': 'application/json' 56 | } 57 | }) 58 | const json = await response.json(); 59 | const items = json.items; 60 | return json.items[0].liveStreamingDetails.activeLiveChatId 61 | } 62 | 63 | const startTyping = (param) => { 64 | let el = document.querySelector(param.el); 65 | el.textContent = ""; 66 | let speed = param.speed; 67 | let string = param.string.split(""); 68 | string.forEach((char, index) => { 69 | setTimeout(() => { 70 | el.textContent += char; 71 | }, speed * index); 72 | }); 73 | }; 74 | 75 | async function getMeboResponse(utterance, username, uid, apikey, agentId) { 76 | var requestBody = { 77 | 'api_key': apikey, 78 | 'agent_id': agentId, 79 | 'utterance': utterance, 80 | 'username': username, 81 | 'uid': uid, 82 | } 83 | const response = await fetch('https://api-mebo.dev/api', { 84 | method: 'post', 85 | headers: { 86 | 'Content-Type': 'application/json' 87 | }, 88 | body: JSON.stringify(requestBody) 89 | }) 90 | const content = await response.json(); 91 | return content.bestResponse.utterance; 92 | } 93 | 94 | const playVoice = async (inputText) => { 95 | audio.pause(); 96 | audio.currentTime = 0; 97 | const ttsQuery = await fetch(VOICE_VOX_API_URL + '/audio_query?speaker=' + VOICEVOX_SPEAKER_ID + '&text=' + encodeURI(inputText), { 98 | method: 'post', 99 | headers: { 100 | 'Content-Type': 'application/json' 101 | } 102 | }) 103 | if (!ttsQuery) return; 104 | const queryJson = await ttsQuery.json(); 105 | const response = await fetch(VOICE_VOX_API_URL + '/synthesis?speaker=' + VOICEVOX_SPEAKER_ID + '&speedScale=2', { 106 | method: 'post', 107 | headers: { 108 | 'Content-Type': 'application/json' 109 | }, 110 | body: JSON.stringify(queryJson) 111 | }) 112 | if (!response) return; 113 | const blob = await response.blob(); 114 | const audioSourceURL = window.URL || window.webkitURL 115 | audio = new Audio(audioSourceURL.createObjectURL(blob)); 116 | audio.onended = function () { 117 | setTimeout(handleNewLiveCommentIfNeeded, 1000); 118 | } 119 | audio.play(); 120 | } 121 | 122 | const visibleAIResponse = () => { 123 | let target = document.getElementById('aiResponse'); 124 | target.style.display = "" 125 | } 126 | 127 | const invisibleAIResponse = () => { 128 | let target = document.getElementById('aiResponse'); 129 | target.style.display = "none" 130 | } 131 | 132 | const handleLiveComment = async (comment, username) => { 133 | isThinking = true; 134 | visibleAIResponse(); 135 | startTyping({ 136 | el: "#aiResponseUtterance", 137 | string: "Thinking................", 138 | speed: 50 139 | }); 140 | let userCommentElement = document.querySelector("#userComment"); 141 | userCommentElement.textContent = username + ": " + comment; 142 | const response = await getMeboResponse(comment, username, LIVE_OWNER_ID, MEBO_API_KEY, MEBO_AGENT_ID); 143 | isThinking = false; 144 | if (username == "") { 145 | await playVoice(response, true, response, false); 146 | } else { 147 | await playVoice(username + "さん、" + response, true, response, false); 148 | } 149 | startTyping({ 150 | el: "#aiResponseUtterance", 151 | string: response, 152 | speed: 50 153 | }); 154 | } 155 | 156 | const retrieveYouTubeLiveComments = (activeLiveChatId) => { 157 | var url = "https://youtube.googleapis.com/youtube/v3/liveChat/messages?liveChatId=" + activeLiveChatId + '&part=authorDetails%2Csnippet&key=' + YOUTUBE_DATA_API_KEY 158 | if (nextPageToken !== "") { 159 | url = url + "&pageToken=" + nextPageToken 160 | } 161 | fetch(url, { 162 | method: 'get', 163 | headers: { 164 | 'Content-Type': 'application/json' 165 | } 166 | }).then( 167 | (response) => { 168 | return response.json(); 169 | } 170 | ).then( 171 | (json) => { 172 | const items = json.items; 173 | let index = 0 174 | nextPageToken = json.nextPageToken; 175 | items?.forEach( 176 | (item) => { 177 | try { 178 | const username = item.authorDetails.displayName; 179 | let message = "" 180 | if (item.snippet.textMessageDetails != undefined) { 181 | // 一般コメント 182 | message = item.snippet.textMessageDetails.messageText; 183 | } 184 | if (item.snippet.superChatDetails != undefined) { 185 | // スパチャコメント 186 | message = item.snippet.superChatDetails.userComment; 187 | } 188 | // :::で区切っているが、適宜オブジェクトで格納するように変更する。 189 | const additionalComment = username + ":::" + message; 190 | if (!liveCommentQueues.includes(additionalComment) && message != "") { 191 | let isNg = false 192 | ngwords.forEach( 193 | (ngWord) => { 194 | if (additionalComment.includes(ngWord)) { 195 | isNg = true 196 | } 197 | } 198 | ) 199 | if (!isNg) { 200 | if (isLiveCommentsRetrieveStarted) { 201 | liveCommentQueues.push(additionalComment) 202 | } else { 203 | responsedLiveComments.push(additionalComment); 204 | } 205 | } 206 | } 207 | } catch { 208 | // Do Nothing 209 | } 210 | index = index + 1 211 | } 212 | ) 213 | } 214 | ).finally( 215 | () => { 216 | setTimeout(retrieveYouTubeLiveComments, INTERVAL_MILL_SECONDS_RETRIEVING_COMMENTS, activeLiveChatId); 217 | } 218 | ) 219 | } 220 | 221 | const getNextComment = () => { 222 | let nextComment = "" 223 | let nextRaw = "" 224 | for (let index in liveCommentQueues) { 225 | if (!responsedLiveComments.includes(liveCommentQueues[index])) { 226 | const arr = liveCommentQueues[index].split(":::") 227 | if (arr.length > 1) { 228 | nextComment = arr[0] + "さんから、「" + arr[1] + "」というコメントが届いているよ。" 229 | nextRaw = arr[1] 230 | break; 231 | } 232 | } 233 | } 234 | return [nextComment, nextRaw]; 235 | } 236 | 237 | const handleNewLiveCommentIfNeeded = async () => { 238 | if (liveCommentQueues.length == 0) { 239 | // QUEUEがなければ何もしない 240 | setTimeout(handleNewLiveCommentIfNeeded, INTERVAL_MILL_SECONDS_HANDLING_COMMENTS); 241 | return; 242 | } 243 | 244 | if (isThinking) { 245 | // VTuberが応答を考えているときは新規コメントを捌かない 246 | setTimeout(handleNewLiveCommentIfNeeded, INTERVAL_MILL_SECONDS_HANDLING_COMMENTS); 247 | return; 248 | } 249 | 250 | if (!audio.ended) { 251 | // VTuberが声を発しているときは新規コメントを捌かない 252 | setTimeout(handleNewLiveCommentIfNeeded, INTERVAL_MILL_SECONDS_HANDLING_COMMENTS); 253 | return; 254 | } 255 | 256 | for (let index in liveCommentQueues) { 257 | if (!responsedLiveComments.includes(liveCommentQueues[index])) { 258 | const arr = liveCommentQueues[index].split(":::") 259 | if (arr.length > 1) { 260 | responsedLiveComments.push(liveCommentQueues[index]); 261 | isThinking = true; 262 | await handleLiveComment(arr[1], arr[0]); 263 | break; 264 | } 265 | } 266 | } 267 | setTimeout(handleNewLiveCommentIfNeeded, 5000); 268 | } 269 | 270 | const onClickSend = () => { 271 | let utterance = document.querySelector("#utterance"); 272 | handleLiveComment(utterance.value, '匿名'); 273 | utterance.value = ""; 274 | } 275 | 276 | // LIVEを開始する 277 | const startLive = () => { 278 | // 明示的にボタンをクリックする等しなければ、音声が再生できない。そのためLIVE開始ボタンを下記のIDで設置する。 279 | let startLiveButton = document.querySelector("#startLiveButton"); 280 | startLiveButton.style.display = "none"; 281 | let submitForm = document.querySelector("#submit_form"); 282 | submitForm.style.display = "none"; 283 | getLiveChatId().then( 284 | (id) => { 285 | retrieveYouTubeLiveComments(id); 286 | } 287 | ) 288 | //LIVE開始時は空文字を送信することで、meboで設定した初回メッセージが返される。 289 | handleLiveComment('', ''); 290 | blink(); 291 | } 292 | 293 | const img = ["chara.png", "chara_blinking.png"]; 294 | var isBlinking = false; 295 | 296 | function blink() { 297 | if (isBlinking) { 298 | isBlinking = false; 299 | document.getElementById("charaImg").src = img[1]; 300 | setTimeout(blink, 100); 301 | } else { 302 | isBlinking = true; 303 | document.getElementById("charaImg").src = img[0]; 304 | setTimeout(blink, 3500); 305 | } 306 | } 307 | 308 | function createUuid() { 309 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (a) { 310 | let r = (new Date().getTime() + Math.random() * 16) % 16 | 0, v = a == 'x' ? r : (r & 0x3 | 0x8); 311 | return v.toString(16); 312 | }); 313 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sample Chat AI VTuber 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 |
17 |

18 |
19 |
20 |

21 | 22 |
23 | 24 | 25 |
26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /publish_agent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k-masashi/sample-chat-ai-vtuber/53c1cd948ffb525505e8c7317620361d8cae46a8/publish_agent.png --------------------------------------------------------------------------------